Skip to content

Commit 2c78cf5

Browse files
committed
Improve coordinate input
1 parent 35f523c commit 2c78cf5

2 files changed

Lines changed: 245 additions & 15 deletions

File tree

opendrift_leeway_webgui/leeway/forms.py

Lines changed: 127 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,108 @@
22
Forms for the web GUI
33
"""
44

5+
from decimal import Decimal
6+
57
from django.contrib.auth import get_user_model
68
from django.contrib.auth.forms import UserCreationForm
7-
from django.forms import CharField, ModelForm, TextInput
9+
from django.core.exceptions import ValidationError
10+
from django.forms import ChoiceField, DecimalField, ModelForm, Select, TextInput
811

912
from .models import LeewaySimulation, Webhook
10-
from .utils import normalize_dms2dec
13+
14+
15+
def _assemble_decimal(deg, minutes, sec):
16+
"""
17+
Assemble a decimal degree value from components.
18+
19+
If *deg* has a fractional part, *minutes* and *sec* are ignored.
20+
If *minutes* has a fractional part, *sec* is ignored.
21+
22+
:param deg: Absolute degrees (non-negative Decimal)
23+
:type deg: ~decimal.Decimal
24+
:param minutes: Minutes component (Decimal)
25+
:type minutes: ~decimal.Decimal
26+
:param sec: Seconds component (Decimal)
27+
:type sec: ~decimal.Decimal
28+
:rtype: ~decimal.Decimal
29+
"""
30+
if deg % 1 != 0:
31+
return deg
32+
if minutes % 1 != 0:
33+
return deg + minutes / 60
34+
return deg + minutes / 60 + sec / 3600
1135

1236

1337
class LeewaySimulationForm(ModelForm):
1438
"""
1539
Add a form for simulations with some tweaks of the default.
40+
41+
Latitude and longitude are each split into four sub-fields
42+
(degrees, minutes, seconds, direction) and assembled into a single
43+
decimal value in the ``clean_latitude`` / ``clean_longitude`` methods.
1644
"""
1745

18-
# Override coordinates fields to allow text input instead of float numbers
19-
longitude = CharField(
20-
help_text="(E/W) Input in decimal and degrees minutes seconds supported.",
21-
widget=TextInput(attrs={"placeholder": "12° 26' 42.684\""}),
46+
#: Direction choices for latitude
47+
LAT_DIR_CHOICES = [("N", "N"), ("S", "S")]
48+
#: Direction choices for longitude
49+
LON_DIR_CHOICES = [("E", "E"), ("W", "W")]
50+
51+
# --- Latitude sub-fields ---
52+
latitude_deg = DecimalField(
53+
label="Latitude",
54+
min_value=Decimal("0"),
55+
max_value=Decimal("90"),
56+
widget=TextInput(attrs={"placeholder": "34", "class": "coord-deg coord-lat"}),
57+
)
58+
latitude_min = DecimalField(
59+
label="",
60+
required=False,
61+
min_value=Decimal("0"),
62+
max_value=Decimal("59.9999999"),
63+
initial=Decimal("0"),
64+
widget=TextInput(attrs={"placeholder": "47", "class": "coord-min coord-lat"}),
65+
)
66+
latitude_sec = DecimalField(
67+
label="",
68+
required=False,
69+
min_value=Decimal("0"),
70+
max_value=Decimal("59.9999999"),
71+
initial=Decimal("0"),
72+
widget=TextInput(attrs={"placeholder": "49.1", "class": "coord-sec coord-lat"}),
73+
)
74+
latitude_dir = ChoiceField(
75+
label="",
76+
choices=LAT_DIR_CHOICES,
77+
widget=Select(attrs={"class": "coord-dir coord-lat"}),
78+
)
79+
80+
# --- Longitude sub-fields ---
81+
longitude_deg = DecimalField(
82+
label="Longitude",
83+
min_value=Decimal("0"),
84+
max_value=Decimal("180"),
85+
widget=TextInput(attrs={"placeholder": "12", "class": "coord-deg coord-lon"}),
86+
)
87+
longitude_min = DecimalField(
88+
label="",
89+
required=False,
90+
min_value=Decimal("0"),
91+
max_value=Decimal("59.9999999"),
92+
initial=Decimal("0"),
93+
widget=TextInput(attrs={"placeholder": "26", "class": "coord-min coord-lon"}),
2294
)
23-
latitude = CharField(
24-
help_text="(N/S) Input in decimal and degrees minutes seconds supported.",
25-
widget=TextInput(attrs={"placeholder": "34° 47' 49.1166\""}),
95+
longitude_sec = DecimalField(
96+
label="",
97+
required=False,
98+
min_value=Decimal("0"),
99+
max_value=Decimal("59.9999999"),
100+
initial=Decimal("0"),
101+
widget=TextInput(attrs={"placeholder": "42.7", "class": "coord-sec coord-lon"}),
102+
)
103+
longitude_dir = ChoiceField(
104+
label="",
105+
choices=LON_DIR_CHOICES,
106+
widget=Select(attrs={"class": "coord-dir coord-lon"}),
26107
)
27108

28109
class Meta:
@@ -49,17 +130,49 @@ class Meta:
49130
"start_time": "All times are UTC. Only simulations +/- 5 days from now are possible.",
50131
}
51132

52-
def clean_longitude(self):
133+
def _clean_coordinate(self, prefix, max_deg):
53134
"""
54-
Convert longitude DMS to decimal
135+
Assemble and validate a coordinate from its sub-fields.
136+
137+
:param prefix: Field name prefix, either ``"latitude"`` or ``"longitude"``
138+
:type prefix: str
139+
:param max_deg: Maximum allowed absolute degree value (90 or 180)
140+
:type max_deg: int
141+
:rtype: float
55142
"""
56-
return normalize_dms2dec(self.cleaned_data.get("longitude", ""))
143+
data = self.cleaned_data
144+
deg = data.get(f"{prefix}_deg")
145+
minutes = data.get(f"{prefix}_min") or Decimal("0")
146+
sec = data.get(f"{prefix}_sec") or Decimal("0")
147+
direction = data.get(f"{prefix}_dir", "N")
148+
149+
if deg is None:
150+
# deg field validation already raised an error; nothing more to do here
151+
raise ValidationError("Degrees are required.")
152+
153+
decimal_value = _assemble_decimal(deg, minutes, sec)
154+
155+
if decimal_value > max_deg:
156+
raise ValidationError(
157+
f"Value {decimal_value} exceeds the maximum of {max_deg}°."
158+
)
159+
160+
if direction in ("S", "W"):
161+
decimal_value = -decimal_value
162+
163+
return float(decimal_value)
57164

58165
def clean_latitude(self):
59166
"""
60-
Convert latitude DMS to decimal
167+
Assemble latitude decimal from sub-fields.
168+
"""
169+
return self._clean_coordinate("latitude", 90)
170+
171+
def clean_longitude(self):
172+
"""
173+
Assemble longitude decimal from sub-fields.
61174
"""
62-
return normalize_dms2dec(self.cleaned_data.get("latitude", ""))
175+
return self._clean_coordinate("longitude", 180)
63176

64177

65178
class WebhookForm(ModelForm):

opendrift_leeway_webgui/leeway/templates/leeway/leewaysimulation_form.html

Lines changed: 118 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,125 @@ <h2>New Leeway Simulation</h2>
55
<form method="post">
66
{% csrf_token %}
77
<table>
8-
{{ form.as_table }}
8+
{{ form.name.errors }}
9+
<tr>
10+
<th>
11+
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}:</label>
12+
</th>
13+
<td>
14+
{{ form.name }}
15+
{% if form.name.help_text %}<span class="helptext">{{ form.name.help_text }}</span>{% endif %}
16+
</td>
17+
</tr>
18+
{{ form.latitude.errors }}
19+
{{ form.latitude_deg.errors }}
20+
{{ form.latitude_min.errors }}
21+
{{ form.latitude_sec.errors }}
22+
{{ form.latitude_dir.errors }}
23+
<tr>
24+
<th>
25+
<label>Latitude:</label>
26+
</th>
27+
<td>
28+
<span class="coord-group">
29+
{{ form.latitude_deg }}<span class="coord-symbol">°</span>
30+
{{ form.latitude_min }}<span class="coord-symbol"></span>
31+
{{ form.latitude_sec }}<span class="coord-symbol"></span>
32+
{{ form.latitude_dir }}
33+
</span>
34+
<span class="helptext">(N/S) Degrees, minutes, seconds. Decimals in degrees override minutes and seconds; decimals in minutes override seconds.</span>
35+
</td>
36+
</tr>
37+
{{ form.longitude.errors }}
38+
{{ form.longitude_deg.errors }}
39+
{{ form.longitude_min.errors }}
40+
{{ form.longitude_sec.errors }}
41+
{{ form.longitude_dir.errors }}
42+
<tr>
43+
<th>
44+
<label>Longitude:</label>
45+
</th>
46+
<td>
47+
<span class="coord-group">
48+
{{ form.longitude_deg }}<span class="coord-symbol">°</span>
49+
{{ form.longitude_min }}<span class="coord-symbol"></span>
50+
{{ form.longitude_sec }}<span class="coord-symbol"></span>
51+
{{ form.longitude_dir }}
52+
</span>
53+
<span class="helptext">(E/W) Degrees, minutes, seconds. Decimals in degrees override minutes and seconds; decimals in minutes override seconds.</span>
54+
</td>
55+
</tr>
56+
{{ form.object_type.errors }}
57+
<tr>
58+
<th>
59+
<label for="{{ form.object_type.id_for_label }}">{{ form.object_type.label }}:</label>
60+
</th>
61+
<td>
62+
{{ form.object_type }}
63+
{% if form.object_type.help_text %}<span class="helptext">{{ form.object_type.help_text }}</span>{% endif %}
64+
</td>
65+
</tr>
66+
{{ form.start_time.errors }}
67+
<tr>
68+
<th>
69+
<label for="{{ form.start_time.id_for_label }}">{{ form.start_time.label }}:</label>
70+
</th>
71+
<td>
72+
{{ form.start_time }}
73+
{% if form.start_time.help_text %}<span class="helptext">{{ form.start_time.help_text }}</span>{% endif %}
74+
</td>
75+
</tr>
76+
{{ form.duration.errors }}
77+
<tr>
78+
<th>
79+
<label for="{{ form.duration.id_for_label }}">{{ form.duration.label }}:</label>
80+
</th>
81+
<td>
82+
{{ form.duration }}
83+
{% if form.duration.help_text %}<span class="helptext">{{ form.duration.help_text }}</span>{% endif %}
84+
</td>
85+
</tr>
86+
{{ form.radius.errors }}
87+
<tr>
88+
<th>
89+
<label for="{{ form.radius.id_for_label }}">{{ form.radius.label }}:</label>
90+
</th>
91+
<td>
92+
{{ form.radius }}
93+
{% if form.radius.help_text %}<span class="helptext">{{ form.radius.help_text }}</span>{% endif %}
94+
</td>
95+
</tr>
996
</table>
1097
<button type="submit">Simulate</button>
1198
</form>
99+
<script>
100+
function hasDecimal(input) {
101+
return input.value.includes('.');
102+
}
103+
104+
function setDisabled(input, disabled) {
105+
input.disabled = disabled;
106+
input.style.opacity = disabled ? '0.4' : '';
107+
}
108+
109+
function updateCoord(degInput, minInput, secInput) {
110+
var degDecimal = hasDecimal(degInput);
111+
var minDecimal = hasDecimal(minInput);
112+
setDisabled(minInput, degDecimal);
113+
setDisabled(secInput, degDecimal || minDecimal);
114+
}
115+
116+
function initCoord(degId, minId, secId) {
117+
var deg = document.getElementById(degId);
118+
var min = document.getElementById(minId);
119+
var sec = document.getElementById(secId);
120+
function update() { updateCoord(deg, min, sec); }
121+
deg.addEventListener('input', update);
122+
min.addEventListener('input', update);
123+
update();
124+
}
125+
126+
initCoord('{{ form.latitude_deg.auto_id }}', '{{ form.latitude_min.auto_id }}', '{{ form.latitude_sec.auto_id }}');
127+
initCoord('{{ form.longitude_deg.auto_id }}', '{{ form.longitude_min.auto_id }}', '{{ form.longitude_sec.auto_id }}');
128+
</script>
12129
{% endblock content %}

0 commit comments

Comments
 (0)