@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.
- package/README.md +169 -2
- package/fesm2022/angular-helpers-openlayers-core.mjs +18 -0
- package/fesm2022/angular-helpers-openlayers-interactions.mjs +12 -0
- package/fesm2022/angular-helpers-openlayers-layers.mjs +141 -13
- package/fesm2022/angular-helpers-openlayers-military.mjs +223 -6
- package/fesm2022/angular-helpers-openlayers-overlays.mjs +439 -8
- package/package.json +6 -2
- package/types/angular-helpers-openlayers-core.d.ts +17 -0
- package/types/angular-helpers-openlayers-interactions.d.ts +7 -0
- package/types/angular-helpers-openlayers-layers.d.ts +48 -34
- package/types/angular-helpers-openlayers-military.d.ts +156 -2
- package/types/angular-helpers-openlayers-overlays.d.ts +196 -11
|
@@ -1,20 +1,237 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { Injectable } from '@angular/core';
|
|
3
3
|
|
|
4
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
15
|
-
geometry: { type: '
|
|
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
|
}
|