geodeploy 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geodeploy/__init__.py +52 -0
- geodeploy/__main__.py +13 -0
- geodeploy/admin.py +191 -0
- geodeploy/catalog.py +96 -0
- geodeploy/cli/__init__.py +7 -0
- geodeploy/cli/commands/__init__.py +2 -0
- geodeploy/cli/commands/_common.py +233 -0
- geodeploy/cli/commands/admin.py +317 -0
- geodeploy/cli/commands/auth.py +280 -0
- geodeploy/cli/commands/browse.py +224 -0
- geodeploy/cli/commands/catalog.py +107 -0
- geodeploy/cli/commands/imports.py +125 -0
- geodeploy/cli/commands/jobs.py +38 -0
- geodeploy/cli/commands/layers.py +536 -0
- geodeploy/cli/commands/portals.py +515 -0
- geodeploy/cli/commands/sources.py +97 -0
- geodeploy/cli/commands/upload.py +154 -0
- geodeploy/cli/main.py +263 -0
- geodeploy/cli/output.py +320 -0
- geodeploy/client.py +327 -0
- geodeploy/config.py +438 -0
- geodeploy/errors.py +93 -0
- geodeploy/imports.py +65 -0
- geodeploy/jobs.py +73 -0
- geodeploy/layers.py +433 -0
- geodeploy/portals.py +280 -0
- geodeploy/py.typed +0 -0
- geodeploy/sources.py +70 -0
- geodeploy/styles.py +467 -0
- geodeploy/transport.py +355 -0
- geodeploy/uploads.py +474 -0
- geodeploy-1.3.0.dist-info/METADATA +102 -0
- geodeploy-1.3.0.dist-info/RECORD +38 -0
- geodeploy-1.3.0.dist-info/WHEEL +5 -0
- geodeploy-1.3.0.dist-info/entry_points.txt +2 -0
- geodeploy-1.3.0.dist-info/licenses/LICENSE +202 -0
- geodeploy-1.3.0.dist-info/licenses/NOTICE +11 -0
- geodeploy-1.3.0.dist-info/top_level.txt +1 -0
geodeploy/styles.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""Symbology — the style dict a layer config carries, built from plain arguments.
|
|
2
|
+
|
|
3
|
+
The vocabulary is `api/geodeploy/services/symbology.py`'s, exactly: single-symbol keys (`color`,
|
|
4
|
+
`fill_opacity`, `outline_color`, `line_width`, `radius`, `marker`, `lineType`) plus the v1.1
|
|
5
|
+
data-driven ones (`color_mode`/`color_field`/`classes`/`categories`/`other_color`,
|
|
6
|
+
`size_mode`/`size_field`/`size_stops`, `extrusion`). This module only ASSEMBLES that dict.
|
|
7
|
+
|
|
8
|
+
**The classification maths is not reimplemented here, on purpose.** Breaks come from
|
|
9
|
+
`GET /data/vector/{ref}/field-stats`, which computes them with the same module the editor preview
|
|
10
|
+
and the published portal use. A second quantile implementation in the client would eventually
|
|
11
|
+
disagree with the server's, and the disagreement would first be visible as a published map whose
|
|
12
|
+
legend does not match its colours — the exact failure `services/symbology.py` was extracted to
|
|
13
|
+
prevent.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from .errors import ValidationError
|
|
20
|
+
|
|
21
|
+
#: Named ramps the server knows (`symbology.RAMPS`). Listed so the CLI can reject a typo up front
|
|
22
|
+
#: instead of silently falling back to viridis on the server.
|
|
23
|
+
RAMPS = ("viridis", "magma", "blues", "reds", "greens", "oranges", "rdbu", "brbg", "spectral")
|
|
24
|
+
|
|
25
|
+
MARKERS = ("circle", "square", "triangle", "diamond", "star", "cross")
|
|
26
|
+
LINE_TYPES = ("solid", "dashed", "dotted")
|
|
27
|
+
CLASSIFY_METHODS = ("quantile", "equal", "jenks")
|
|
28
|
+
COLOR_MODES = ("single", "graduated", "categorized")
|
|
29
|
+
|
|
30
|
+
#: `outline_color="none"` means draw NO outline — a sentinel, not an empty string, because "" is
|
|
31
|
+
#: what an uninitialised colour input produces and would silently turn outlines off.
|
|
32
|
+
NO_OUTLINE = "none"
|
|
33
|
+
|
|
34
|
+
#: CLI/keyword name → the key the style dict actually uses. Only `line_type` differs, and it
|
|
35
|
+
#: differs because the stored key is camelCase (`lineType`) for historical reasons.
|
|
36
|
+
_SIMPLE_KEYS = (
|
|
37
|
+
("color", "color"),
|
|
38
|
+
("fill_opacity", "fill_opacity"),
|
|
39
|
+
("outline_color", "outline_color"),
|
|
40
|
+
("outline_width", "outline_width"),
|
|
41
|
+
("line_width", "line_width"),
|
|
42
|
+
("radius", "radius"),
|
|
43
|
+
("marker", "marker"),
|
|
44
|
+
("line_type", "lineType"),
|
|
45
|
+
("colormap", "colormap"),
|
|
46
|
+
("rescale", "rescale"),
|
|
47
|
+
("algorithm", "algorithm"),
|
|
48
|
+
("zfactor", "zfactor"),
|
|
49
|
+
("bidx", "bidx"),
|
|
50
|
+
("other_color", "other_color"),
|
|
51
|
+
("color_field", "color_field"),
|
|
52
|
+
("color_mode", "color_mode"),
|
|
53
|
+
("color_ramp", "color_ramp"),
|
|
54
|
+
("color_ramp_reverse", "color_ramp_reverse"),
|
|
55
|
+
("classes_n", "classes_n"),
|
|
56
|
+
("size_field", "size_field"),
|
|
57
|
+
("size_mode", "size_mode"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def build_style(base: Optional[Dict[str, Any]] = None, **kw: Any) -> Dict[str, Any]:
|
|
62
|
+
"""Merge only the styling arguments that were actually given onto `base`.
|
|
63
|
+
|
|
64
|
+
"Only what was given" is the whole contract: `geodeploy portals style … --color red` must not
|
|
65
|
+
reset the marker shape and the classes someone spent ten minutes on, so an argument left at
|
|
66
|
+
None is absent, not a default.
|
|
67
|
+
"""
|
|
68
|
+
style = dict(base or {})
|
|
69
|
+
for arg, key in _SIMPLE_KEYS:
|
|
70
|
+
value = kw.get(arg)
|
|
71
|
+
if value is not None:
|
|
72
|
+
style[key] = value
|
|
73
|
+
|
|
74
|
+
_validate(style)
|
|
75
|
+
|
|
76
|
+
stops = kw.get("size_stops")
|
|
77
|
+
if stops is not None:
|
|
78
|
+
style["size_stops"] = parse_size_stops(stops)
|
|
79
|
+
style.setdefault("size_mode", "proportional")
|
|
80
|
+
if style.get("size_mode") == "proportional" and not style.get("size_field"):
|
|
81
|
+
raise ValidationError(400, "Proportional size needs a field (--size-field).")
|
|
82
|
+
|
|
83
|
+
classes = kw.get("classes")
|
|
84
|
+
if classes is not None:
|
|
85
|
+
style["classes"] = classes
|
|
86
|
+
style["color_mode"] = "graduated"
|
|
87
|
+
categories = kw.get("categories")
|
|
88
|
+
if categories is not None:
|
|
89
|
+
style["categories"] = categories
|
|
90
|
+
style["color_mode"] = "categorized"
|
|
91
|
+
|
|
92
|
+
extrusion = build_extrusion(style.get("extrusion"), **kw)
|
|
93
|
+
if extrusion is not None:
|
|
94
|
+
style["extrusion"] = extrusion
|
|
95
|
+
|
|
96
|
+
if kw.get("clear_classification"):
|
|
97
|
+
for key in ("color_mode", "color_field", "classes", "categories", "other_color"):
|
|
98
|
+
style.pop(key, None)
|
|
99
|
+
return style
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_extrusion(base: Optional[Dict[str, Any]] = None, **kw: Any) -> Optional[Dict[str, Any]]:
|
|
103
|
+
"""The 3D block, or None when no 3D argument was given.
|
|
104
|
+
|
|
105
|
+
Points get a footprint `radius` in METRES (a point has no area, so extruding one needs a width
|
|
106
|
+
that polygons get for free); left unset, the server derives it from the layer's own extent —
|
|
107
|
+
which is why this does not invent a default: a fixed 30 m bar is invisible on a world map.
|
|
108
|
+
"""
|
|
109
|
+
fields = (("extrude", "enabled"), ("extrude_field", "field"), ("extrude_scale", "scale"),
|
|
110
|
+
("extrude_base", "base"), ("extrude_color", "color"),
|
|
111
|
+
("extrude_opacity", "opacity"), ("extrude_radius", "radius"))
|
|
112
|
+
given = {key: kw.get(arg) for arg, key in fields if kw.get(arg) is not None}
|
|
113
|
+
if not given:
|
|
114
|
+
return None
|
|
115
|
+
out = dict(base or {})
|
|
116
|
+
out.update(given)
|
|
117
|
+
if out.get("enabled") and not (out.get("field") or out.get("height")):
|
|
118
|
+
raise ValidationError(400, "3D extrusion needs a numeric field (--extrude-field).")
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_size_stops(raw: Any) -> List[List[float]]:
|
|
123
|
+
"""`"0:2,100:12"` (or an already-parsed list) → `[[0, 2], [100, 12]]`, ascending.
|
|
124
|
+
|
|
125
|
+
Two stops minimum, because `interpolate` between one point is not an interpolation — the server
|
|
126
|
+
silently falls back to the fixed radius, which looks like the flag did nothing.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(raw, (list, tuple)):
|
|
129
|
+
pairs = [list(p) for p in raw]
|
|
130
|
+
else:
|
|
131
|
+
pairs = []
|
|
132
|
+
for chunk in str(raw).split(","):
|
|
133
|
+
chunk = chunk.strip()
|
|
134
|
+
if not chunk:
|
|
135
|
+
continue
|
|
136
|
+
if ":" not in chunk:
|
|
137
|
+
raise ValidationError(400, "Size stops look like value:size — got {0!r}.".format(chunk))
|
|
138
|
+
value, _, size = chunk.partition(":")
|
|
139
|
+
try:
|
|
140
|
+
pairs.append([float(value), float(size)])
|
|
141
|
+
except ValueError:
|
|
142
|
+
raise ValidationError(400, "Size stop {0!r} is not numeric.".format(chunk))
|
|
143
|
+
if len(pairs) < 2:
|
|
144
|
+
raise ValidationError(400, "Give at least two size stops, e.g. 0:2,1000:12.")
|
|
145
|
+
return sorted(pairs, key=lambda p: p[0])
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_classes(raw: Any) -> List[Dict[str, Any]]:
|
|
149
|
+
"""`"0-10:#fee,10-50:#f00"` → the `classes` list. `*` is an open edge (`<10`, `≥50`)."""
|
|
150
|
+
if isinstance(raw, list):
|
|
151
|
+
return raw
|
|
152
|
+
out = [] # type: List[Dict[str, Any]]
|
|
153
|
+
for chunk in str(raw).split(","):
|
|
154
|
+
chunk = chunk.strip()
|
|
155
|
+
if not chunk:
|
|
156
|
+
continue
|
|
157
|
+
span, _, color = chunk.rpartition(":")
|
|
158
|
+
if not span or not color:
|
|
159
|
+
raise ValidationError(400, "Classes look like min-max:#colour — got {0!r}.".format(chunk))
|
|
160
|
+
lo, _, hi = span.partition("-")
|
|
161
|
+
out.append({"min": _edge(lo), "max": _edge(hi), "color": color.strip()})
|
|
162
|
+
if not out:
|
|
163
|
+
raise ValidationError(400, "No classes parsed.")
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_categories(raw: Any) -> List[Dict[str, Any]]:
|
|
168
|
+
"""`"forest:#2c7,water:#39f"` → the `categories` list."""
|
|
169
|
+
if isinstance(raw, list):
|
|
170
|
+
return raw
|
|
171
|
+
out = [] # type: List[Dict[str, Any]]
|
|
172
|
+
for chunk in str(raw).split(","):
|
|
173
|
+
chunk = chunk.strip()
|
|
174
|
+
if not chunk:
|
|
175
|
+
continue
|
|
176
|
+
value, _, color = chunk.rpartition(":")
|
|
177
|
+
if not value or not color:
|
|
178
|
+
raise ValidationError(400, "Categories look like value:#colour — got {0!r}.".format(chunk))
|
|
179
|
+
out.append({"value": value.strip(), "color": color.strip()})
|
|
180
|
+
if not out:
|
|
181
|
+
raise ValidationError(400, "No categories parsed.")
|
|
182
|
+
return out
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _edge(text: str) -> Optional[float]:
|
|
186
|
+
text = (text or "").strip()
|
|
187
|
+
if text in ("", "*", "inf", "-inf", "none"):
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
return float(text)
|
|
191
|
+
except ValueError:
|
|
192
|
+
raise ValidationError(400, "{0!r} is not a number.".format(text))
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def classify(client: Any, layer_ref: Any, field: str, mode: Optional[str] = None,
|
|
196
|
+
classes: int = 5, method: str = "quantile", ramp: str = "viridis",
|
|
197
|
+
reverse: bool = False,
|
|
198
|
+
base: Optional[Dict[str, Any]] = None) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
|
199
|
+
"""Ask the server to classify `field`, and fold the answer into a style dict.
|
|
200
|
+
|
|
201
|
+
Returns `(style, stats)` — the stats carry the distribution the server saw, which is what makes
|
|
202
|
+
"why are all my features one colour" answerable (a column that is 90 % one value collapses its
|
|
203
|
+
quantile breaks, and the server drops the empty classes rather than publish a legend the map
|
|
204
|
+
does not match).
|
|
205
|
+
"""
|
|
206
|
+
if method not in CLASSIFY_METHODS:
|
|
207
|
+
raise ValidationError(400, "Classification method must be one of {0}.".format(
|
|
208
|
+
", ".join(CLASSIFY_METHODS)))
|
|
209
|
+
if ramp not in RAMPS:
|
|
210
|
+
raise ValidationError(400, "Unknown colour ramp {0!r}. Known: {1}".format(
|
|
211
|
+
ramp, ", ".join(RAMPS)))
|
|
212
|
+
stats = client.vector.field_stats(layer_ref, field, classes=classes, method=method, ramp=ramp,
|
|
213
|
+
reverse=reverse)
|
|
214
|
+
suggestion = (stats or {}).get("suggestion") or {}
|
|
215
|
+
kind = (stats or {}).get("kind")
|
|
216
|
+
resolved = mode or suggestion.get("color_mode") or (
|
|
217
|
+
"graduated" if kind == "numeric" else "categorized")
|
|
218
|
+
|
|
219
|
+
style = dict(base or {})
|
|
220
|
+
style["color_field"] = field
|
|
221
|
+
style["color_mode"] = resolved
|
|
222
|
+
style["color_ramp"] = ramp
|
|
223
|
+
# Recorded so the direction survives a re-classify: the class COLOURS are stored per class, so
|
|
224
|
+
# without this a later change of method or class count would silently un-reverse the ramp.
|
|
225
|
+
style["color_ramp_reverse"] = bool(reverse)
|
|
226
|
+
# What was ASKED for, which is not always what came back — repeated values collapse a break, and
|
|
227
|
+
# some columns yield one class however many you request. The editor shows this number in its
|
|
228
|
+
# Classes box; storing it here means a CLI-classified layer opens in the browser showing the
|
|
229
|
+
# count you asked for rather than the count you got.
|
|
230
|
+
style["classes_n"] = int(classes)
|
|
231
|
+
if resolved == "graduated":
|
|
232
|
+
found = suggestion.get("classes") or []
|
|
233
|
+
if not found:
|
|
234
|
+
raise ValidationError(
|
|
235
|
+
400, "{0!r} produced no classes — it is {1}, and graduated colouring needs a "
|
|
236
|
+
"numeric column.".format(field, kind or "not numeric"))
|
|
237
|
+
style["classes"] = found
|
|
238
|
+
style.pop("categories", None)
|
|
239
|
+
else:
|
|
240
|
+
found = suggestion.get("categories") or []
|
|
241
|
+
if not found:
|
|
242
|
+
raise ValidationError(400, "{0!r} produced no categories.".format(field))
|
|
243
|
+
style["categories"] = found
|
|
244
|
+
style.pop("classes", None)
|
|
245
|
+
return style, stats
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _validate(style: Dict[str, Any]) -> None:
|
|
249
|
+
marker = style.get("marker")
|
|
250
|
+
if marker is not None and marker not in MARKERS:
|
|
251
|
+
raise ValidationError(400, "Marker must be one of {0}.".format(", ".join(MARKERS)))
|
|
252
|
+
line_type = style.get("lineType")
|
|
253
|
+
if line_type is not None and line_type not in LINE_TYPES:
|
|
254
|
+
raise ValidationError(400, "Line type must be one of {0}.".format(", ".join(LINE_TYPES)))
|
|
255
|
+
mode = style.get("color_mode")
|
|
256
|
+
if mode is not None and mode not in COLOR_MODES:
|
|
257
|
+
raise ValidationError(400, "Colour mode must be one of {0}.".format(", ".join(COLOR_MODES)))
|
|
258
|
+
for key in ("fill_opacity", "outline_width"):
|
|
259
|
+
value = style.get(key)
|
|
260
|
+
if value is not None and not 0 <= float(value) <= 1:
|
|
261
|
+
raise ValidationError(400, "{0} is a fraction between 0 and 1.".format(key))
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
class Style(object):
|
|
265
|
+
"""A stored style, READ. The reverse direction of `build_style`.
|
|
266
|
+
|
|
267
|
+
`build_style` exists so a caller can assemble the vocabulary; nothing existed to take it apart
|
|
268
|
+
again, so anything rendering a GeoDeploy layer somewhere else — the QGIS plugin first — had to
|
|
269
|
+
reach into the dict and re-decide what `color_mode: "graduated"` implies, which keys are
|
|
270
|
+
authoritative, and what a missing one defaults to. That is a second definition of the
|
|
271
|
+
vocabulary, in a project that already keeps three surfaces in step by hand.
|
|
272
|
+
|
|
273
|
+
So this is a READER, not a schema: it never rejects, never fills in a server default it cannot
|
|
274
|
+
know, and keeps `.raw` so a caller can reach anything not modelled here. `to_dict()` returns the
|
|
275
|
+
original dict, which is what makes build → parse → build lossless.
|
|
276
|
+
"""
|
|
277
|
+
|
|
278
|
+
__slots__ = ("raw",)
|
|
279
|
+
|
|
280
|
+
def __init__(self, style: Optional[Dict[str, Any]] = None):
|
|
281
|
+
self.raw = dict(style or {})
|
|
282
|
+
|
|
283
|
+
# -- what kind of symbology is this ------------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def mode(self) -> str:
|
|
287
|
+
"""`single` | `graduated` | `categorized` — never None, so a caller can switch on it."""
|
|
288
|
+
return self.raw.get("color_mode") or "single"
|
|
289
|
+
|
|
290
|
+
@property
|
|
291
|
+
def field(self) -> Optional[str]:
|
|
292
|
+
"""The attribute the colours are driven by, or None for a single symbol."""
|
|
293
|
+
return self.raw.get("color_field") if self.mode != "single" else None
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def is_data_driven(self) -> bool:
|
|
297
|
+
return bool(self.field) and self.mode in ("graduated", "categorized")
|
|
298
|
+
|
|
299
|
+
# -- the pieces a renderer needs ---------------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
@property
|
|
302
|
+
def color(self) -> Optional[str]:
|
|
303
|
+
return self.raw.get("color")
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def classes(self) -> List[Dict[str, Any]]:
|
|
307
|
+
"""`[{min, max, color}]` for a graduated style. `min`/`max` may be None at the ends — that
|
|
308
|
+
is an OPEN bucket ("< 10", "≥ 90"), not missing data."""
|
|
309
|
+
return [c for c in (self.raw.get("classes") or []) if isinstance(c, dict)]
|
|
310
|
+
|
|
311
|
+
@property
|
|
312
|
+
def categories(self) -> List[Dict[str, Any]]:
|
|
313
|
+
"""`[{value, color}]` for a categorized style. Anything not listed takes `other_color`."""
|
|
314
|
+
return [c for c in (self.raw.get("categories") or []) if isinstance(c, dict)]
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def other_color(self) -> Optional[str]:
|
|
318
|
+
return self.raw.get("other_color")
|
|
319
|
+
|
|
320
|
+
@property
|
|
321
|
+
def requested_classes(self) -> Optional[int]:
|
|
322
|
+
"""How many classes were ASKED for. `len(style.classes)` is how many came back, and the two
|
|
323
|
+
differ whenever repeated values collapse a break — so a UI that shows the second number as
|
|
324
|
+
the input fights the user (issue #10)."""
|
|
325
|
+
value = self.raw.get("classes_n")
|
|
326
|
+
return int(value) if isinstance(value, (int, float)) else None
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def ramp(self) -> Optional[str]:
|
|
330
|
+
"""The ramp the classes were generated from, when one was recorded. The class colours are
|
|
331
|
+
stored per class, so this is provenance — not what a renderer should read to draw."""
|
|
332
|
+
return self.raw.get("color_ramp")
|
|
333
|
+
|
|
334
|
+
@property
|
|
335
|
+
def ramp_reversed(self) -> bool:
|
|
336
|
+
return bool(self.raw.get("color_ramp_reverse"))
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def size(self) -> Optional[Dict[str, Any]]:
|
|
340
|
+
"""`{"field": …, "stops": [[value, size], …]}` when size is data-driven, else None.
|
|
341
|
+
|
|
342
|
+
Both halves are required: a field with no stops cannot be interpolated, and stops with no
|
|
343
|
+
field have nothing to read.
|
|
344
|
+
"""
|
|
345
|
+
field, stops = self.raw.get("size_field"), self.raw.get("size_stops")
|
|
346
|
+
if not field or not stops:
|
|
347
|
+
return None
|
|
348
|
+
return {"field": field, "stops": [list(s) for s in stops if len(s) == 2]}
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def extrusion(self) -> Optional[Dict[str, Any]]:
|
|
352
|
+
"""The 3D block when it is switched ON. `{"enabled": False}` reads as None: a renderer that
|
|
353
|
+
checks truthiness on the dict alone would extrude a layer the author turned off."""
|
|
354
|
+
ext = self.raw.get("extrusion")
|
|
355
|
+
if isinstance(ext, dict) and ext.get("enabled"):
|
|
356
|
+
return dict(ext)
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
# -- raster ------------------------------------------------------------------------------------
|
|
360
|
+
|
|
361
|
+
@property
|
|
362
|
+
def colormap(self) -> Optional[str]:
|
|
363
|
+
return self.raw.get("colormap")
|
|
364
|
+
|
|
365
|
+
@property
|
|
366
|
+
def rescale(self) -> Optional[List[float]]:
|
|
367
|
+
"""The stretch as numbers. Stored as TiTiler wants it — the string "min,max" — so a caller
|
|
368
|
+
that wants to compute with it should not have to parse it again."""
|
|
369
|
+
value = self.raw.get("rescale")
|
|
370
|
+
if isinstance(value, str):
|
|
371
|
+
try:
|
|
372
|
+
return [float(v) for v in value.split(",")]
|
|
373
|
+
except ValueError:
|
|
374
|
+
return None
|
|
375
|
+
if isinstance(value, (list, tuple)) and len(value) == 2:
|
|
376
|
+
try:
|
|
377
|
+
return [float(v) for v in value]
|
|
378
|
+
except (TypeError, ValueError):
|
|
379
|
+
return None
|
|
380
|
+
return None
|
|
381
|
+
|
|
382
|
+
# -- output --------------------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
def legend(self) -> List[Dict[str, Any]]:
|
|
385
|
+
"""Swatches and labels, WITHOUT asking the server — mirrors `symbology.legend_entries`.
|
|
386
|
+
|
|
387
|
+
Prefer `client.vector.legend(ref)`: the server's answer is the one the portal drew. This is
|
|
388
|
+
for a caller that already holds a style dict (a portal's layer_config, an unsaved edit in a
|
|
389
|
+
dialog) and cannot make a request per keystroke. The two agree; `test_styles_jobs` pins the
|
|
390
|
+
labels against the server's format.
|
|
391
|
+
"""
|
|
392
|
+
if self.mode == "graduated":
|
|
393
|
+
out = []
|
|
394
|
+
for c in self.classes:
|
|
395
|
+
lo, hi = c.get("min"), c.get("max")
|
|
396
|
+
if lo is None and hi is None:
|
|
397
|
+
label = "all"
|
|
398
|
+
elif lo is None:
|
|
399
|
+
label = "< {0}".format(_legend_num(hi))
|
|
400
|
+
elif hi is None:
|
|
401
|
+
label = "≥ {0}".format(_legend_num(lo))
|
|
402
|
+
else:
|
|
403
|
+
label = "{0} – {1}".format(_legend_num(lo), _legend_num(hi))
|
|
404
|
+
out.append({"color": c.get("color"), "label": label})
|
|
405
|
+
return out
|
|
406
|
+
if self.mode == "categorized":
|
|
407
|
+
out = [{"color": c.get("color"), "label": str(c.get("value"))} for c in self.categories]
|
|
408
|
+
if out:
|
|
409
|
+
out.append({"color": self.other_color or "#9ca3af", "label": "Other"})
|
|
410
|
+
return out
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
414
|
+
"""The style as stored — unchanged, so `build_style(**…)` round-trips through this."""
|
|
415
|
+
return dict(self.raw)
|
|
416
|
+
|
|
417
|
+
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
418
|
+
return "<Style {0}>".format(describe(self.raw))
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def parse(style: Optional[Dict[str, Any]]) -> Style:
|
|
422
|
+
"""Read a stored style dict. Accepts a layer's `default_style` wrapper or the inner style."""
|
|
423
|
+
if isinstance(style, dict) and "style" in style and isinstance(style["style"], dict):
|
|
424
|
+
# A layer record carries {opacity, style: {...}, popup_fields}; a portal layer_config the
|
|
425
|
+
# same. Taking either means a caller never has to remember which one they are holding.
|
|
426
|
+
return Style(style["style"])
|
|
427
|
+
return Style(style)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
#: The package-level name. `styles.parse` reads well inside this module; at the top level, next to
|
|
431
|
+
#: `Client`, a bare `parse` says nothing about what it parses.
|
|
432
|
+
parse_style = parse
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _legend_num(value: Any) -> str:
|
|
436
|
+
"""Legend numbers, formatted as `symbology._num` does — an integer stays an integer."""
|
|
437
|
+
try:
|
|
438
|
+
number = float(value)
|
|
439
|
+
except (TypeError, ValueError):
|
|
440
|
+
return str(value)
|
|
441
|
+
if number == int(number) and abs(number) < 1e15:
|
|
442
|
+
return str(int(number))
|
|
443
|
+
return "{0:,.2f}".format(number).rstrip("0").rstrip(".")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def describe(style: Dict[str, Any]) -> str:
|
|
447
|
+
"""A one-line human summary of a style — what a table cell shows."""
|
|
448
|
+
if not style:
|
|
449
|
+
return "default"
|
|
450
|
+
mode = style.get("color_mode") or "single"
|
|
451
|
+
if mode == "graduated" and style.get("color_field"):
|
|
452
|
+
return "graduated on {0} ({1} classes)".format(style["color_field"],
|
|
453
|
+
len(style.get("classes") or []))
|
|
454
|
+
if mode == "categorized" and style.get("color_field"):
|
|
455
|
+
return "categorized on {0} ({1} values)".format(style["color_field"],
|
|
456
|
+
len(style.get("categories") or []))
|
|
457
|
+
bits = [] # type: List[str]
|
|
458
|
+
for key in ("color", "marker", "radius", "line_width", "colormap", "rescale"):
|
|
459
|
+
if style.get(key) is not None:
|
|
460
|
+
bits.append("{0}={1}".format(key, style[key]))
|
|
461
|
+
if (style.get("extrusion") or {}).get("enabled"):
|
|
462
|
+
bits.append("3D")
|
|
463
|
+
return ", ".join(bits) or "default"
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def merge_iterable(pairs: Iterable[Tuple[str, Any]]) -> Dict[str, Any]: # pragma: no cover - util
|
|
467
|
+
return {k: v for k, v in pairs if v is not None}
|