@angular-helpers/openlayers 0.2.0 → 0.4.0

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.
@@ -1,20 +1,237 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { Injectable } from '@angular/core';
3
3
 
4
- // OlMilitaryService
4
+ // @angular-helpers/openlayers/military — service implementation
5
+ /**
6
+ * Meters per degree of latitude on a spherical Earth approximation.
7
+ * Used by the local tangent-plane projection in the geometry helpers.
8
+ */
9
+ const METERS_PER_DEGREE_LAT = 111_320;
10
+ /**
11
+ * Service exposing geometry helpers and MIL-STD-2525 symbology rendering.
12
+ *
13
+ * - `createEllipse`, `createSector`, `createDonut` are **pure math** and
14
+ * have no runtime dependencies beyond the bundled types.
15
+ * - `createMilSymbol` uses the milsymbol library via dynamic ESM import.
16
+ */
5
17
  class OlMilitaryService {
18
+ idCounter = 0;
19
+ mlLoader = null;
20
+ msModule = null;
21
+ // ---------------------------------------------------------------------------
22
+ // Geometry helpers (pure math, no deps)
23
+ // ---------------------------------------------------------------------------
24
+ /**
25
+ * Build a `Feature<Polygon>` approximating an ellipse centered at
26
+ * `config.center`. See {@link EllipseConfig} for parameter semantics.
27
+ */
6
28
  createEllipse(config) {
7
- return { id: 'ellipse-1', geometry: { type: 'Polygon', coordinates: [] } };
29
+ const { center, semiMajor, semiMinor, rotation = 0, segments = 64, properties } = config;
30
+ if (semiMajor <= 0 || semiMinor <= 0) {
31
+ throw new RangeError('semiMajor and semiMinor must be positive');
32
+ }
33
+ if (segments < 8) {
34
+ throw new RangeError('segments must be >= 8');
35
+ }
36
+ const cosR = Math.cos(rotation);
37
+ const sinR = Math.sin(rotation);
38
+ const ring = [];
39
+ for (let i = 0; i < segments; i++) {
40
+ const theta = (i / segments) * Math.PI * 2;
41
+ // Ellipse in local axis-aligned frame, then rotated by `rotation`.
42
+ const ax = Math.cos(theta) * semiMajor;
43
+ const ay = Math.sin(theta) * semiMinor;
44
+ const dx = ax * cosR - ay * sinR;
45
+ const dy = ax * sinR + ay * cosR;
46
+ ring.push(this.offsetMetersToLonLat(center, dx, dy));
47
+ }
48
+ ring.push(ring[0]); // close the ring
49
+ return {
50
+ id: this.nextId('ellipse'),
51
+ geometry: { type: 'Polygon', coordinates: [ring] },
52
+ properties,
53
+ };
8
54
  }
55
+ /**
56
+ * Build a `Feature<Polygon>` for a circular sector (pie slice).
57
+ * See {@link SectorConfig} for parameter semantics.
58
+ */
9
59
  createSector(config) {
10
- return { id: 'sector-1', geometry: { type: 'Polygon', coordinates: [] } };
60
+ const { center, radius, startAngle, endAngle, segments = 32, properties } = config;
61
+ if (radius <= 0) {
62
+ throw new RangeError('radius must be positive');
63
+ }
64
+ if (endAngle <= startAngle) {
65
+ throw new RangeError('endAngle must be greater than startAngle');
66
+ }
67
+ if (endAngle - startAngle > Math.PI * 2) {
68
+ throw new RangeError('sector cannot exceed full circle');
69
+ }
70
+ if (segments < 4) {
71
+ throw new RangeError('segments must be >= 4');
72
+ }
73
+ const ring = [center];
74
+ const span = endAngle - startAngle;
75
+ for (let i = 0; i <= segments; i++) {
76
+ const theta = startAngle + (i / segments) * span;
77
+ const dx = Math.cos(theta) * radius;
78
+ const dy = Math.sin(theta) * radius;
79
+ ring.push(this.offsetMetersToLonLat(center, dx, dy));
80
+ }
81
+ ring.push(center); // close back to apex
82
+ return {
83
+ id: this.nextId('sector'),
84
+ geometry: { type: 'Polygon', coordinates: [ring] },
85
+ properties,
86
+ };
11
87
  }
12
- addMilSymbol(config) {
88
+ /**
89
+ * Build a `Feature<Polygon>` for a donut (annular ring). The output has
90
+ * two rings: an outer ring wound counter-clockwise and an inner ring
91
+ * wound clockwise so the GeoJSON right-hand rule renders the hole.
92
+ */
93
+ createDonut(config) {
94
+ const { center, outerRadius, innerRadius, segments = 64, properties } = config;
95
+ if (outerRadius <= 0 || innerRadius <= 0) {
96
+ throw new RangeError('radii must be positive');
97
+ }
98
+ if (outerRadius <= innerRadius) {
99
+ throw new RangeError('outerRadius must be greater than innerRadius');
100
+ }
101
+ if (segments < 8) {
102
+ throw new RangeError('segments must be >= 8');
103
+ }
104
+ const outer = [];
105
+ const inner = [];
106
+ for (let i = 0; i < segments; i++) {
107
+ const theta = (i / segments) * Math.PI * 2;
108
+ const cosT = Math.cos(theta);
109
+ const sinT = Math.sin(theta);
110
+ // Outer ring: CCW (theta increasing)
111
+ outer.push(this.offsetMetersToLonLat(center, cosT * outerRadius, sinT * outerRadius));
112
+ // Inner ring: CW — sample the SAME thetas but we'll reverse the
113
+ // accumulator below so the ring is traversed in the opposite sense.
114
+ inner.push(this.offsetMetersToLonLat(center, cosT * innerRadius, sinT * innerRadius));
115
+ }
116
+ inner.reverse();
117
+ outer.push(outer[0]);
118
+ inner.push(inner[0]);
13
119
  return {
14
- id: `symbol-${config.sidc}`,
15
- geometry: { type: 'Point', coordinates: config.position },
120
+ id: this.nextId('donut'),
121
+ geometry: { type: 'Polygon', coordinates: [outer, inner] },
122
+ properties,
16
123
  };
17
124
  }
125
+ // ---------------------------------------------------------------------------
126
+ // MIL-STD-2525 symbology (lazy `milsymbol` load)
127
+ // ---------------------------------------------------------------------------
128
+ /**
129
+ * Pre-load the optional `milsymbol` peer dependency so subsequent calls
130
+ * to `createMilSymbol` / `createMilSymbolSync` resolve immediately.
131
+ * Idempotent — multiple calls share the same promise.
132
+ */
133
+ preloadMilsymbol() {
134
+ this.assertBrowser();
135
+ if (!this.mlLoader) {
136
+ this.mlLoader = import('milsymbol').then((m) => {
137
+ this.msModule = m;
138
+ return m;
139
+ });
140
+ }
141
+ return this.mlLoader.then(() => {
142
+ // Void return for the public API
143
+ });
144
+ }
145
+ /**
146
+ * Build a MIL-STD-2525 symbol feature asynchronously.
147
+ * Lazy-loads `milsymbol` on the first call.
148
+ */
149
+ async createMilSymbol(config) {
150
+ this.assertBrowser();
151
+ this.assertSidc(config.sidc);
152
+ if (!this.msModule) {
153
+ await this.preloadMilsymbol();
154
+ }
155
+ return this.buildSymbolFeature(config);
156
+ }
157
+ /**
158
+ * Build a MIL-STD-2525 symbol feature synchronously.
159
+ * Throws if `milsymbol` has not been preloaded via `preloadMilsymbol()`
160
+ * or a previous `createMilSymbol()` call.
161
+ */
162
+ createMilSymbolSync(config) {
163
+ this.assertBrowser();
164
+ this.assertSidc(config.sidc);
165
+ if (!this.msModule) {
166
+ throw new Error('milsymbol is not loaded yet. Call preloadMilsymbol() or use the async createMilSymbol().');
167
+ }
168
+ return this.buildSymbolFeature(config);
169
+ }
170
+ // ---------------------------------------------------------------------------
171
+ // Internals
172
+ // ---------------------------------------------------------------------------
173
+ /**
174
+ * Project an `(dx, dy)` meter offset from `center` to lon/lat using a
175
+ * local tangent-plane (equirectangular) approximation. Acceptable for
176
+ * the radii typical in military symbology (<100 km from center).
177
+ */
178
+ offsetMetersToLonLat(center, dx, dy) {
179
+ const [lon, lat] = center;
180
+ const latRad = (lat * Math.PI) / 180;
181
+ const dLat = dy / METERS_PER_DEGREE_LAT;
182
+ const dLon = dx / (METERS_PER_DEGREE_LAT * Math.cos(latRad));
183
+ return [lon + dLon, lat + dLat];
184
+ }
185
+ nextId(kind) {
186
+ return `${kind}-${++this.idCounter}`;
187
+ }
188
+ buildSymbolFeature(config) {
189
+ const { sidc, position, properties, quantity, ...rest } = config;
190
+ // `milsymbol` types `quantity` as a string, but a number is the
191
+ // ergonomic shape; coerce here.
192
+ const milOptions = {
193
+ ...rest,
194
+ ...(quantity !== undefined ? { quantity: String(quantity) } : {}),
195
+ };
196
+ // We asserted this.msModule exists before calling this
197
+ const ms = this.msModule;
198
+ // default import might be wrapped depending on bundler
199
+ const SymbolClass = ms.default?.Symbol || ms.Symbol;
200
+ const symbol = new SymbolClass(sidc, milOptions);
201
+ const style = this.symbolToStyleResult(symbol);
202
+ const mergedProperties = { sidc, ...milOptions, ...properties };
203
+ return {
204
+ id: this.nextId('symbol'),
205
+ geometry: { type: 'Point', coordinates: position },
206
+ properties: mergedProperties,
207
+ style: { icon: { src: style.src, size: style.size, anchor: style.anchor } },
208
+ };
209
+ }
210
+ symbolToStyleResult(symbol) {
211
+ const svg = symbol.asSVG();
212
+ const { width, height } = symbol.getSize();
213
+ const { x: ax, y: ay } = symbol.getAnchor();
214
+ return {
215
+ src: `data:image/svg+xml;base64,${this.encodeBase64Utf8(svg)}`,
216
+ size: [width, height],
217
+ anchor: [ax / width, ay / height],
218
+ };
219
+ }
220
+ encodeBase64Utf8(input) {
221
+ // `btoa` only handles Latin-1; this round-trip preserves non-ASCII
222
+ // characters (e.g. unit designators with accents).
223
+ return btoa(unescape(encodeURIComponent(input)));
224
+ }
225
+ assertSidc(sidc) {
226
+ if (typeof sidc !== 'string' || sidc.length < 10) {
227
+ throw new TypeError('sidc must be a non-empty MIL-STD-2525 SIDC string');
228
+ }
229
+ }
230
+ assertBrowser() {
231
+ if (typeof window === 'undefined') {
232
+ throw new Error('createMilSymbol requires a browser environment');
233
+ }
234
+ }
18
235
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlMilitaryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
19
236
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlMilitaryService });
20
237
  }