@orbat-mapper/control-measures 0.2.0-alpha.1 → 0.2.0-alpha.10

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.
@@ -0,0 +1,60 @@
1
+ import { X as ControlMeasureId } from "../index-D3uCYKe3.mjs";
2
+ import { FeatureCollection, Geometry } from "geojson";
3
+
4
+ //#region src/preview/index.d.ts
5
+ /**
6
+ * Renders a measure's representative preview to GeoJSON. Pure — no map engine,
7
+ * so it runs identically at build time (docs catalog, README generator) and in
8
+ * the browser (preview app). Returns an empty collection if the generator
9
+ * throws, so a single broken measure never breaks a whole gallery.
10
+ */
11
+ declare function renderRepresentative(id: ControlMeasureId): FeatureCollection<Geometry, unknown>;
12
+ /** A styling-free preview primitive. Consumers supply stroke, radius, font,
13
+ * and colors when they draw it. */
14
+ interface PreviewShape {
15
+ type: "polyline" | "polygon" | "circle" | "text";
16
+ /** SVG path data for `polyline`/`polygon`. */
17
+ d?: string;
18
+ /** Center for `circle`/`text`. */
19
+ cx?: number;
20
+ cy?: number;
21
+ /** Whether a `polygon` is a doctrinal solid (arrowhead) vs an area outline. */
22
+ filled?: boolean;
23
+ /** Glyph text for `text` (e.g. breach's "B"). */
24
+ text?: string;
25
+ /** Generator-frame rotation (radians) for `text`. */
26
+ rotation?: number;
27
+ /**
28
+ * Text height in the same SVG units as `d`/`cx`/`cy`, derived from the
29
+ * source label's ground-anchored `textSizePixels`/`textSizeResolution`
30
+ * (ADR-0027) scaled through the fit into the viewBox, then clamped so a
31
+ * long string can't exceed ~40% of the viewBox width. Present only when the
32
+ * label carried real sizing; a fixed glyph with no sizing (e.g. breach's
33
+ * "B", or Boundary's echelon-only sample) leaves this `undefined` — a
34
+ * consumer should fall back to its own fixed font size, matching behavior
35
+ * from before this field existed.
36
+ */
37
+ heightPx?: number;
38
+ /** Horizontal alignment relative to (`cx`, `cy`), mirroring the label
39
+ * feature's `textAnchor` — `"start"`/`"end"` anchor the text at the point
40
+ * instead of centering it. Undefined = centered. */
41
+ textAnchor?: "start" | "end";
42
+ }
43
+ /** Target viewBox the geometry is fit into. */
44
+ interface ProjectDimensions {
45
+ width: number;
46
+ height: number;
47
+ pad: number;
48
+ }
49
+ /**
50
+ * Fits a rendered `FeatureCollection` into a `width`×`height` viewBox (inset by
51
+ * `pad`) and emits styling-free {@link PreviewShape}s. North is up (y is
52
+ * flipped). Returns `ok: false` for an empty/degenerate render so consumers can
53
+ * show a geometry-typed fallback glyph.
54
+ */
55
+ declare function projectRenderToShapes(fc: FeatureCollection<Geometry, unknown>, dims: ProjectDimensions): {
56
+ shapes: PreviewShape[];
57
+ ok: boolean;
58
+ };
59
+ //#endregion
60
+ export { PreviewShape, ProjectDimensions, projectRenderToShapes, renderRepresentative };
@@ -0,0 +1,254 @@
1
+ import { c as DEFINITIONS, t as renderControlMeasure } from "../renderControlMeasure-BblcXXLp.mjs";
2
+ //#region src/preview/index.ts
3
+ /**
4
+ * Unitless `previewSample` points (~`[-1, 1]`) are scaled by this degree offset
5
+ * before reaching a generator. Most generators operate in projected meters, so
6
+ * small degree offsets near the origin yield visible geometry. The single place
7
+ * that knows preview points are degree offsets feeding projected-meter
8
+ * generators.
9
+ */
10
+ const D = .005;
11
+ /**
12
+ * Generic, category-level layout for measures without a `previewSample`
13
+ * override — keyed off `geometry`/`minCoordinates`/`entityType` so a whole
14
+ * class shares one layout rather than each sibling repeating it. Per-measure
15
+ * quirks live on the definition (ADR-0025). Points are unitless.
16
+ */
17
+ function fallbackControlPoints(m) {
18
+ const min = m.minCoordinates ?? 2;
19
+ if (m.geometry === "point" || min <= 1) return [[0, 0]];
20
+ if (min === 2) return [[-1, 0], [1, 0]];
21
+ if (m.entityType === "Axis of Advance") return [
22
+ [-1, -.4],
23
+ [1, -.4],
24
+ [1.8, -.4],
25
+ [0, .6]
26
+ ];
27
+ if (min === 3) return [
28
+ [-1, -.4],
29
+ [1, -.4],
30
+ [0, .6]
31
+ ];
32
+ return [
33
+ [-.3, -.3],
34
+ [.3, -.3],
35
+ [-.6, .1],
36
+ [.6, .1]
37
+ ];
38
+ }
39
+ /**
40
+ * Resolves a measure's representative input: its co-located `previewSample`
41
+ * override if present, otherwise the generic {@link fallbackControlPoints}.
42
+ * Unitless points are scaled by {@link D} to the degree offsets a generator
43
+ * expects.
44
+ */
45
+ function representativeSample(id) {
46
+ const definition = DEFINITIONS[id];
47
+ const sample = definition.previewSample;
48
+ const controlPoints = (sample ? sample.controlPoints : fallbackControlPoints(definition.metadata)).map(([x, y]) => [x * D, y * D]);
49
+ const options = sample?.options;
50
+ const textAmplifiers = sample?.textAmplifiers;
51
+ return {
52
+ controlPoints,
53
+ ...options ? { options } : {},
54
+ ...textAmplifiers ? { textAmplifiers } : {}
55
+ };
56
+ }
57
+ /**
58
+ * Renders a measure's representative preview to GeoJSON. Pure — no map engine,
59
+ * so it runs identically at build time (docs catalog, README generator) and in
60
+ * the browser (preview app). Returns an empty collection if the generator
61
+ * throws, so a single broken measure never breaks a whole gallery.
62
+ */
63
+ function renderRepresentative(id) {
64
+ try {
65
+ const { controlPoints, options, textAmplifiers } = representativeSample(id);
66
+ return renderControlMeasure({
67
+ id: "preview",
68
+ kind: id,
69
+ controlPoints,
70
+ ...options ? { options } : {},
71
+ ...textAmplifiers ? { textAmplifiers } : {}
72
+ });
73
+ } catch {
74
+ return {
75
+ type: "FeatureCollection",
76
+ features: []
77
+ };
78
+ }
79
+ }
80
+ /**
81
+ * Fits a rendered `FeatureCollection` into a `width`×`height` viewBox (inset by
82
+ * `pad`) and emits styling-free {@link PreviewShape}s. North is up (y is
83
+ * flipped). Returns `ok: false` for an empty/degenerate render so consumers can
84
+ * show a geometry-typed fallback glyph.
85
+ */
86
+ function projectRenderToShapes(fc, dims) {
87
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
88
+ const walk = (p) => {
89
+ if (p[0] < minX) minX = p[0];
90
+ if (p[0] > maxX) maxX = p[0];
91
+ if (p[1] < minY) minY = p[1];
92
+ if (p[1] > maxY) maxY = p[1];
93
+ };
94
+ for (const f of fc.features) collectCoords(f.geometry, walk);
95
+ if (!isFinite(minX) || !isFinite(minY)) return {
96
+ shapes: [],
97
+ ok: false
98
+ };
99
+ const spanX = Math.max(maxX - minX, 1e-9);
100
+ const spanY = Math.max(maxY - minY, 1e-9);
101
+ const innerW = dims.width - dims.pad * 2;
102
+ const innerH = dims.height - dims.pad * 2;
103
+ const scale = Math.min(innerW / spanX, innerH / spanY);
104
+ const offsetX = dims.pad + (innerW - spanX * scale) / 2;
105
+ const offsetY = dims.pad + (innerH - spanY * scale) / 2;
106
+ const tx = (p) => [offsetX + (p[0] - minX) * scale, offsetY + (maxY - p[1]) * scale];
107
+ const shapes = [];
108
+ const bboxArea = spanX * spanY;
109
+ const textLimits = {
110
+ scale,
111
+ maxWidthPx: dims.width * MAX_TEXT_WIDTH_RATIO,
112
+ maxHeightPx: dims.height * MAX_TEXT_HEIGHT_RATIO
113
+ };
114
+ for (const f of fc.features) collectShapes(f.geometry, tx, shapes, bboxArea, textLimits, f.properties);
115
+ return {
116
+ shapes,
117
+ ok: shapes.length > 0
118
+ };
119
+ }
120
+ /** Rough average glyph width, as a ratio of height, for a bold sans-serif
121
+ * label — used only to estimate whether a string will overflow the viewBox,
122
+ * not to lay out real text. */
123
+ const CHAR_WIDTH_RATIO = .62;
124
+ /** A text shape may not claim more than this fraction of the viewBox width;
125
+ * longer strings shrink their `heightPx` to fit. */
126
+ const MAX_TEXT_WIDTH_RATIO = .4;
127
+ /**
128
+ * A text shape may not claim more than this fraction of the viewBox height —
129
+ * guards short strings (e.g. "LL", "BHL") whose ground-anchored size would
130
+ * otherwise fit the width cap easily but still tower over the sample line.
131
+ * Close to the fixed font sizes consumers used before `heightPx` existed.
132
+ */
133
+ const MAX_TEXT_HEIGHT_RATIO = .25;
134
+ /**
135
+ * Resolves a label's SVG-unit text height from its `textSizePixels`/
136
+ * `textSizeResolution` properties (ground meters = pixels × resolution),
137
+ * scaled by the same `scale` the geometry was fit with, then clamped by both
138
+ * the height cap and the width the string would occupy at that height (so a
139
+ * long string can't overflow `maxWidthPx`). Returns `undefined` when the label
140
+ * carries no real sizing — the caller leaves `heightPx` unset.
141
+ */
142
+ function resolveTextHeightPx(labelProps, text, limits) {
143
+ const sizePixels = labelProps.textSizePixels;
144
+ const sizeResolution = labelProps.textSizeResolution;
145
+ if (typeof sizePixels !== "number" || typeof sizeResolution !== "number") return void 0;
146
+ const raw = sizePixels * sizeResolution * limits.scale;
147
+ const widthLimit = text.length > 0 ? limits.maxWidthPx / (text.length * CHAR_WIDTH_RATIO) : Infinity;
148
+ return Math.min(raw, limits.maxHeightPx, widthLimit);
149
+ }
150
+ function ringArea(ring) {
151
+ let a = 0;
152
+ for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) a += (ring[j][0] - ring[i][0]) * (ring[j][1] + ring[i][1]);
153
+ return Math.abs(a / 2);
154
+ }
155
+ function collectCoords(g, push) {
156
+ switch (g.type) {
157
+ case "Point":
158
+ push(g.coordinates);
159
+ break;
160
+ case "MultiPoint":
161
+ case "LineString":
162
+ g.coordinates.forEach(push);
163
+ break;
164
+ case "MultiLineString":
165
+ case "Polygon":
166
+ g.coordinates.forEach((ring) => ring.forEach(push));
167
+ break;
168
+ case "MultiPolygon":
169
+ g.coordinates.forEach((poly) => poly.forEach((ring) => ring.forEach(push)));
170
+ break;
171
+ case "GeometryCollection":
172
+ g.geometries.forEach((sub) => collectCoords(sub, push));
173
+ break;
174
+ }
175
+ }
176
+ function pathFromRing(ring, tx, close) {
177
+ if (ring.length === 0) return "";
178
+ const parts = ring.map((p, i) => {
179
+ const [x, y] = tx(p);
180
+ return `${i === 0 ? "M" : "L"}${x.toFixed(2)} ${y.toFixed(2)}`;
181
+ });
182
+ if (close) parts.push("Z");
183
+ return parts.join(" ");
184
+ }
185
+ function collectShapes(g, tx, out, bboxArea, textLimits, props) {
186
+ const labelProps = props && typeof props === "object" ? props : {};
187
+ const labelText = typeof labelProps.text === "string" ? labelProps.text : void 0;
188
+ const labelRotation = typeof labelProps.rotation === "number" ? labelProps.rotation : void 0;
189
+ const labelAnchor = labelProps.textAnchor === "start" || labelProps.textAnchor === "end" ? labelProps.textAnchor : void 0;
190
+ const polygonFilled = (ring) => bboxArea > 0 && ringArea(ring) / bboxArea < .25;
191
+ switch (g.type) {
192
+ case "Point": {
193
+ const [x, y] = tx(g.coordinates);
194
+ if (labelText !== void 0) {
195
+ const heightPx = resolveTextHeightPx(labelProps, labelText, textLimits);
196
+ out.push({
197
+ type: "text",
198
+ cx: x,
199
+ cy: y,
200
+ text: labelText,
201
+ rotation: labelRotation,
202
+ heightPx,
203
+ textAnchor: labelAnchor
204
+ });
205
+ } else out.push({
206
+ type: "circle",
207
+ cx: x,
208
+ cy: y
209
+ });
210
+ break;
211
+ }
212
+ case "MultiPoint":
213
+ g.coordinates.forEach((p) => {
214
+ const [x, y] = tx(p);
215
+ out.push({
216
+ type: "circle",
217
+ cx: x,
218
+ cy: y
219
+ });
220
+ });
221
+ break;
222
+ case "LineString":
223
+ out.push({
224
+ type: "polyline",
225
+ d: pathFromRing(g.coordinates, tx, false)
226
+ });
227
+ break;
228
+ case "MultiLineString":
229
+ g.coordinates.forEach((line) => out.push({
230
+ type: "polyline",
231
+ d: pathFromRing(line, tx, false)
232
+ }));
233
+ break;
234
+ case "Polygon":
235
+ g.coordinates.forEach((ring) => out.push({
236
+ type: "polygon",
237
+ d: pathFromRing(ring, tx, true),
238
+ filled: polygonFilled(ring)
239
+ }));
240
+ break;
241
+ case "MultiPolygon":
242
+ g.coordinates.forEach((poly) => poly.forEach((ring) => out.push({
243
+ type: "polygon",
244
+ d: pathFromRing(ring, tx, true),
245
+ filled: polygonFilled(ring)
246
+ })));
247
+ break;
248
+ case "GeometryCollection":
249
+ g.geometries.forEach((sub) => collectShapes(sub, tx, out, bboxArea, textLimits, props));
250
+ break;
251
+ }
252
+ }
253
+ //#endregion
254
+ export { projectRenderToShapes, renderRepresentative };