@angular-helpers/openlayers 0.2.0 → 0.3.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 CHANGED
@@ -97,6 +97,85 @@ export class MapComponent {
97
97
  }
98
98
  ```
99
99
 
100
+ ## Overlays — popups and tooltips
101
+
102
+ Available since `0.3.0` from `@angular-helpers/openlayers/overlays`.
103
+
104
+ ### `<ol-popup>` — declarative popup with content projection
105
+
106
+ The popup's host element is used directly as the underlying `ol/Overlay` element, so projected children stay inside Angular's view tree and benefit from change detection without any extra plumbing.
107
+
108
+ ```typescript
109
+ import { OlPopupComponent } from '@angular-helpers/openlayers/overlays';
110
+
111
+ @Component({
112
+ imports: [OlMapComponent, OlVectorLayerComponent, OlPopupComponent],
113
+ template: `
114
+ <ol-map [center]="[2.17, 41.38]" [zoom]="12">
115
+ <ol-vector-layer id="cities" [features]="cities()" />
116
+
117
+ <ol-popup
118
+ [position]="selectedCoord()"
119
+ [closeButton]="true"
120
+ [autoPan]="true"
121
+ (closed)="clearSelection()"
122
+ >
123
+ <h3>{{ selected()?.name }}</h3>
124
+ <p>{{ selected()?.description }}</p>
125
+ </ol-popup>
126
+ </ol-map>
127
+ `,
128
+ })
129
+ export class MyMap {
130
+ // …
131
+ }
132
+ ```
133
+
134
+ Setting `[position]="null"` hides the popup and emits `closed`.
135
+
136
+ ### `[olTooltip]` — feature hover tooltip
137
+
138
+ ```html
139
+ <ol-vector-layer
140
+ id="cities"
141
+ [features]="cities()"
142
+ [olTooltip]="'name'"
143
+ [olTooltipLayer]="'cities'"
144
+ />
145
+ ```
146
+
147
+ Reads `feature.get('name')` for any feature on layer `cities` under the cursor and renders a styled `<div role="tooltip">` near the pointer. Use the `.ol-tooltip` class to override the default look.
148
+
149
+ ### `OlPopupService` — programmatic popups
150
+
151
+ Three content modes from a service:
152
+
153
+ ```typescript
154
+ const popups = inject(OlPopupService);
155
+
156
+ // 1) Plain text / HTMLElement
157
+ popups.open({
158
+ id: 'simple',
159
+ position: [2.17, 41.38],
160
+ content: 'Hello map',
161
+ positioning: 'bottom-center',
162
+ autoPan: true,
163
+ });
164
+
165
+ // 2) Dynamic Angular component (createComponent + hostElement)
166
+ const handle = popups.openComponent({
167
+ id: 'city-popup',
168
+ position: [2.17, 41.38],
169
+ component: CityCardComponent,
170
+ bindings: [
171
+ inputBinding('city', () => selected()),
172
+ outputBinding<void>('closed', () => handle.close()),
173
+ ],
174
+ });
175
+ ```
176
+
177
+ `open` is idempotent by `id` and updates the existing overlay in place. `openComponent` always recreates the `ComponentRef` on a repeated id and disposes the previous one (`appRef.detachView` + `ref.destroy`) to avoid CD leaks. Calls made before the map is ready are queued and replayed on `OlMapService.onReady`.
178
+
100
179
  ## Architecture
101
180
 
102
181
  ### Data vs UI Separation
@@ -12,7 +12,7 @@ import OSM from 'ol/source/OSM';
12
12
  import XYZ from 'ol/source/XYZ';
13
13
  import TileWMS from 'ol/source/TileWMS';
14
14
  import ImageWMS from 'ol/source/ImageWMS';
15
- import { ImageStatic } from 'ol/source';
15
+ import ImageStatic from 'ol/source/ImageStatic';
16
16
  import { OlMapService } from '@angular-helpers/openlayers/core';
17
17
 
18
18
  // OlLayerService
@@ -1,19 +1,449 @@
1
1
  import * as i0 from '@angular/core';
2
- import { Injectable } from '@angular/core';
2
+ import { inject, ElementRef, input, output, DestroyRef, effect, ChangeDetectionStrategy, Component, Directive, EnvironmentInjector, ApplicationRef, createComponent, Injectable } from '@angular/core';
3
+ import Overlay from 'ol/Overlay';
4
+ import { OlMapService, OlZoneHelper } from '@angular-helpers/openlayers/core';
5
+
6
+ // OlPopupComponent — declarative popup with content projection.
7
+ /**
8
+ * Declarative map popup that projects arbitrary Angular content via `<ng-content>`.
9
+ *
10
+ * The component's host element is used directly as the `ol/Overlay`'s element, so
11
+ * projected children stay inside Angular's component tree and benefit from change
12
+ * detection without any extra plumbing.
13
+ *
14
+ * @example
15
+ * ```html
16
+ * <ol-popup
17
+ * [position]="selectedCoord()"
18
+ * [closeButton]="true"
19
+ * [autoPan]="true"
20
+ * (closed)="clearSelection()"
21
+ * >
22
+ * <h3>{{ selected()?.name }}</h3>
23
+ * <p>{{ selected()?.description }}</p>
24
+ * </ol-popup>
25
+ * ```
26
+ */
27
+ class OlPopupComponent {
28
+ elementRef = inject(ElementRef);
29
+ mapService = inject(OlMapService);
30
+ zoneHelper = inject(OlZoneHelper);
31
+ /** Map coordinate where the popup is anchored. `null` hides the popup. */
32
+ position = input(null, ...(ngDevMode ? [{ debugName: "position" }] : /* istanbul ignore next */ []));
33
+ /** Pixel offset relative to `position`. */
34
+ offset = input([0, 0], ...(ngDevMode ? [{ debugName: "offset" }] : /* istanbul ignore next */ []));
35
+ /** Anchor of the popup element relative to `position`. */
36
+ positioning = input('bottom-center', ...(ngDevMode ? [{ debugName: "positioning" }] : /* istanbul ignore next */ []));
37
+ /** Whether the map auto-pans to keep the popup in view. */
38
+ autoPan = input(false, ...(ngDevMode ? [{ debugName: "autoPan" }] : /* istanbul ignore next */ []));
39
+ /** Whether to render the default close button. */
40
+ closeButton = input(false, ...(ngDevMode ? [{ debugName: "closeButton" }] : /* istanbul ignore next */ []));
41
+ /** Emitted when the popup transitions from visible to hidden (close button or `position` set to null). */
42
+ closed = output();
43
+ overlay = null;
44
+ currentMap = null;
45
+ wasVisible = false;
46
+ constructor() {
47
+ inject(DestroyRef).onDestroy(() => this.dispose());
48
+ // Create the overlay lazily when the map becomes ready, then keep it in sync
49
+ // with the inputs. The overlay's element is THIS component's host element so
50
+ // projected content lives inside Angular's view tree.
51
+ this.mapService.onReady((map) => {
52
+ this.currentMap = map;
53
+ this.zoneHelper.runOutsideAngular(() => {
54
+ this.overlay = new Overlay({
55
+ element: this.elementRef.nativeElement,
56
+ stopEvent: true,
57
+ });
58
+ });
59
+ this.applyState();
60
+ });
61
+ effect(() => {
62
+ // Read every signal so the effect re-runs on any change.
63
+ this.position();
64
+ this.offset();
65
+ this.positioning();
66
+ this.autoPan();
67
+ this.applyState();
68
+ });
69
+ }
70
+ /** @internal */
71
+ onCloseClick() {
72
+ this.zoneHelper.runInsideAngular(() => this.closed.emit());
73
+ }
74
+ applyState() {
75
+ const overlay = this.overlay;
76
+ const map = this.currentMap;
77
+ if (!overlay || !map)
78
+ return;
79
+ const position = this.position();
80
+ this.zoneHelper.runOutsideAngular(() => {
81
+ overlay.setOffset(this.offset());
82
+ overlay.setPositioning(this.positioning());
83
+ const wantsAutoPan = this.autoPan();
84
+ overlay.set('autoPan', wantsAutoPan);
85
+ const visible = position !== null;
86
+ if (visible && !this.wasVisible) {
87
+ map.addOverlay(overlay);
88
+ }
89
+ overlay.setPosition(visible ? position : undefined);
90
+ if (!visible && this.wasVisible) {
91
+ map.removeOverlay(overlay);
92
+ this.zoneHelper.runInsideAngular(() => this.closed.emit());
93
+ }
94
+ this.wasVisible = visible;
95
+ });
96
+ }
97
+ dispose() {
98
+ if (!this.overlay || !this.currentMap)
99
+ return;
100
+ const overlay = this.overlay;
101
+ const map = this.currentMap;
102
+ this.zoneHelper.runOutsideAngular(() => {
103
+ if (this.wasVisible)
104
+ map.removeOverlay(overlay);
105
+ });
106
+ this.overlay = null;
107
+ this.currentMap = null;
108
+ }
109
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlPopupComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
110
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.4", type: OlPopupComponent, isStandalone: true, selector: "ol-popup", inputs: { position: { classPropertyName: "position", publicName: "position", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "offset", isSignal: true, isRequired: false, transformFunction: null }, positioning: { classPropertyName: "positioning", publicName: "positioning", isSignal: true, isRequired: false, transformFunction: null }, autoPan: { classPropertyName: "autoPan", publicName: "autoPan", isSignal: true, isRequired: false, transformFunction: null }, closeButton: { classPropertyName: "closeButton", publicName: "closeButton", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { closed: "closed" }, host: { attributes: { "role": "dialog" } }, ngImport: i0, template: `
111
+ @if (closeButton()) {
112
+ <button type="button" class="ol-popup-close" aria-label="Close" (click)="onCloseClick()">
113
+ ×
114
+ </button>
115
+ }
116
+ <ng-content />
117
+ `, isInline: true, styles: [":host{display:block;position:relative}.ol-popup-close{position:absolute;top:4px;right:4px;width:20px;height:20px;padding:0;border:none;background:transparent;font-size:16px;line-height:1;cursor:pointer;color:inherit}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
118
+ }
119
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlPopupComponent, decorators: [{
120
+ type: Component,
121
+ args: [{ selector: 'ol-popup', changeDetection: ChangeDetectionStrategy.OnPush, template: `
122
+ @if (closeButton()) {
123
+ <button type="button" class="ol-popup-close" aria-label="Close" (click)="onCloseClick()">
124
+ ×
125
+ </button>
126
+ }
127
+ <ng-content />
128
+ `, host: {
129
+ role: 'dialog',
130
+ }, styles: [":host{display:block;position:relative}.ol-popup-close{position:absolute;top:4px;right:4px;width:20px;height:20px;padding:0;border:none;background:transparent;font-size:16px;line-height:1;cursor:pointer;color:inherit}\n"] }]
131
+ }], ctorParameters: () => [], propDecorators: { position: [{ type: i0.Input, args: [{ isSignal: true, alias: "position", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "offset", required: false }] }], positioning: [{ type: i0.Input, args: [{ isSignal: true, alias: "positioning", required: false }] }], autoPan: [{ type: i0.Input, args: [{ isSignal: true, alias: "autoPan", required: false }] }], closeButton: [{ type: i0.Input, args: [{ isSignal: true, alias: "closeButton", required: false }] }], closed: [{ type: i0.Output, args: ["closed"] }] } });
132
+
133
+ // OlTooltipDirective — feature hover tooltip on a vector layer.
134
+ /**
135
+ * Shows a floating tooltip whose text is read from a feature property when the
136
+ * pointer hovers over it. The tooltip element is appended to the map viewport
137
+ * and styled minimally; consumers can override via the `.ol-tooltip` CSS hook.
138
+ *
139
+ * @example
140
+ * ```html
141
+ * <ol-vector-layer
142
+ * id="cities"
143
+ * [features]="cities()"
144
+ * [olTooltip]="'name'"
145
+ * />
146
+ * ```
147
+ *
148
+ * Apply `[olTooltipLayer]` to limit detection to a single layer (matches the
149
+ * value of the layer's `id` property). Without it the tooltip reacts to any
150
+ * feature found at the cursor.
151
+ */
152
+ class OlTooltipDirective {
153
+ mapService = inject(OlMapService);
154
+ zoneHelper = inject(OlZoneHelper);
155
+ /** Property key to read from the hovered feature. */
156
+ olTooltip = input.required(...(ngDevMode ? [{ debugName: "olTooltip" }] : /* istanbul ignore next */ []));
157
+ /** Optional layer id; when set, only features on that layer trigger the tooltip. */
158
+ olTooltipLayer = input(null, ...(ngDevMode ? [{ debugName: "olTooltipLayer" }] : /* istanbul ignore next */ []));
159
+ element = null;
160
+ listener = null;
161
+ currentMap = null;
162
+ constructor() {
163
+ inject(DestroyRef).onDestroy(() => this.dispose());
164
+ this.mapService.onReady((map) => {
165
+ this.currentMap = map;
166
+ this.zoneHelper.runOutsideAngular(() => {
167
+ const viewport = map.getViewport();
168
+ const tooltip = document.createElement('div');
169
+ tooltip.setAttribute('role', 'tooltip');
170
+ tooltip.className = 'ol-tooltip';
171
+ Object.assign(tooltip.style, {
172
+ position: 'absolute',
173
+ pointerEvents: 'none',
174
+ padding: '4px 8px',
175
+ background: 'rgba(0, 0, 0, 0.75)',
176
+ color: '#fff',
177
+ fontSize: '12px',
178
+ borderRadius: '4px',
179
+ display: 'none',
180
+ whiteSpace: 'nowrap',
181
+ zIndex: '1000',
182
+ });
183
+ viewport.appendChild(tooltip);
184
+ this.element = tooltip;
185
+ const handler = (event) => {
186
+ this.handlePointerMove(event, map);
187
+ };
188
+ map.on('pointermove', handler);
189
+ this.listener = handler;
190
+ });
191
+ });
192
+ }
193
+ handlePointerMove(event, map) {
194
+ const tooltip = this.element;
195
+ if (!tooltip)
196
+ return;
197
+ const layerId = this.olTooltipLayer();
198
+ const propKey = this.olTooltip();
199
+ let matched = null;
200
+ map.forEachFeatureAtPixel(event.pixel, (feature) => {
201
+ matched = feature;
202
+ return true;
203
+ }, {
204
+ layerFilter: layerId ? (layer) => layer.get('id') === layerId : undefined,
205
+ });
206
+ if (!matched) {
207
+ tooltip.style.display = 'none';
208
+ return;
209
+ }
210
+ const value = matched.get(propKey);
211
+ if (value === undefined || value === null) {
212
+ tooltip.style.display = 'none';
213
+ return;
214
+ }
215
+ tooltip.textContent = String(value);
216
+ tooltip.style.display = 'block';
217
+ const [x, y] = event.pixel;
218
+ tooltip.style.left = `${x + 12}px`;
219
+ tooltip.style.top = `${y + 12}px`;
220
+ }
221
+ dispose() {
222
+ const map = this.currentMap;
223
+ const listener = this.listener;
224
+ if (map && listener) {
225
+ this.zoneHelper.runOutsideAngular(() => map.un('pointermove', listener));
226
+ }
227
+ if (this.element?.parentNode) {
228
+ this.element.parentNode.removeChild(this.element);
229
+ }
230
+ this.element = null;
231
+ this.listener = null;
232
+ this.currentMap = null;
233
+ }
234
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlTooltipDirective, deps: [], target: i0.ɵɵFactoryTarget.Directive });
235
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.4", type: OlTooltipDirective, isStandalone: true, selector: "[olTooltip]", inputs: { olTooltip: { classPropertyName: "olTooltip", publicName: "olTooltip", isSignal: true, isRequired: true, transformFunction: null }, olTooltipLayer: { classPropertyName: "olTooltipLayer", publicName: "olTooltipLayer", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
236
+ }
237
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlTooltipDirective, decorators: [{
238
+ type: Directive,
239
+ args: [{
240
+ selector: '[olTooltip]',
241
+ }]
242
+ }], ctorParameters: () => [], propDecorators: { olTooltip: [{ type: i0.Input, args: [{ isSignal: true, alias: "olTooltip", required: true }] }], olTooltipLayer: [{ type: i0.Input, args: [{ isSignal: true, alias: "olTooltipLayer", required: false }] }] } });
3
243
 
4
244
  // OlPopupService
245
+ /**
246
+ * Manages OpenLayers popup overlays anchored to map coordinates.
247
+ *
248
+ * Three content modes:
249
+ * - `open()` — string text or `HTMLElement`
250
+ * - `openComponent()` — dynamic Angular component via `createComponent + hostElement`
251
+ * - The `<ol-popup>` component delegates `open()` internally for declarative usage.
252
+ *
253
+ * All calls are idempotent by `id`. Calls made before the underlying `ol/Map` is ready
254
+ * are queued and replayed in order once `OlMapService.onReady` fires.
255
+ */
5
256
  class OlPopupService {
6
- showPopup(options) {
7
- return 'popup-id';
257
+ mapService = inject(OlMapService);
258
+ zoneHelper = inject(OlZoneHelper);
259
+ envInjector = inject(EnvironmentInjector);
260
+ appRef = inject(ApplicationRef);
261
+ popups = new Map();
262
+ pending = [];
263
+ flushSubscribed = false;
264
+ constructor() {
265
+ inject(DestroyRef).onDestroy(() => this.closeAll());
266
+ }
267
+ /**
268
+ * Open a popup with `string` or `HTMLElement` content.
269
+ *
270
+ * Idempotent by `id`: a second call with the same id updates `position` and
271
+ * `content` of the existing popup instead of creating a duplicate.
272
+ */
273
+ open(options) {
274
+ const id = options.id ?? generateId('popup');
275
+ const map = this.mapService.getMap();
276
+ if (!map) {
277
+ this.queue({ kind: 'open', options: { ...options, id } });
278
+ return { id, close: () => this.close(id) };
279
+ }
280
+ this.createOrUpdate(id, options, map);
281
+ return { id, close: () => this.close(id) };
282
+ }
283
+ /**
284
+ * Open a popup whose content is a dynamically-instantiated Angular component.
285
+ *
286
+ * Uses `createComponent({ environmentInjector, hostElement, bindings, directives })`
287
+ * (Angular 16.2+) and attaches the host view to `ApplicationRef` so change detection
288
+ * reaches the rendered component.
289
+ *
290
+ * Idempotent by `id`: a previous `ComponentRef` registered under the same id is
291
+ * destroyed before the new one is created.
292
+ */
293
+ openComponent(options) {
294
+ const id = options.id ?? generateId('popup');
295
+ const map = this.mapService.getMap();
296
+ if (!map) {
297
+ this.queue({
298
+ kind: 'openComponent',
299
+ options: { ...options, id },
300
+ });
301
+ return { id, close: () => this.close(id) };
302
+ }
303
+ this.createOrUpdateComponent(id, options, map);
304
+ return { id, close: () => this.close(id) };
305
+ }
306
+ /** Close and dispose a single popup by id. No-op when the id is unknown. */
307
+ close(id) {
308
+ const managed = this.popups.get(id);
309
+ if (!managed)
310
+ return;
311
+ managed.dispose();
312
+ this.popups.delete(id);
313
+ }
314
+ /** Close every managed popup. */
315
+ closeAll() {
316
+ const ids = [...this.popups.keys()];
317
+ for (const id of ids) {
318
+ this.close(id);
319
+ }
320
+ this.pending.length = 0;
321
+ }
322
+ // ---------------------------------------------------------------------------
323
+ // Internals
324
+ // ---------------------------------------------------------------------------
325
+ queue(call) {
326
+ this.pending.push(call);
327
+ if (this.flushSubscribed)
328
+ return;
329
+ this.flushSubscribed = true;
330
+ this.mapService.onReady((map) => this.flushPending(map));
331
+ }
332
+ flushPending(map) {
333
+ const pending = this.pending.splice(0);
334
+ for (const call of pending) {
335
+ if (call.kind === 'open') {
336
+ this.createOrUpdate(call.options.id, call.options, map);
337
+ }
338
+ else {
339
+ this.createOrUpdateComponent(call.options.id, call.options, map);
340
+ }
341
+ }
342
+ }
343
+ createOrUpdate(id, options, map) {
344
+ const existing = this.popups.get(id);
345
+ if (existing && !existing.componentRef) {
346
+ // Update position / element of an existing string/HTMLElement popup in place.
347
+ this.zoneHelper.runOutsideAngular(() => {
348
+ existing.overlay.setPosition(options.position);
349
+ replaceElementContent(existing.overlay.getElement(), options.content);
350
+ applyOverlayConfig(existing.overlay, options);
351
+ });
352
+ return;
353
+ }
354
+ if (existing) {
355
+ // Type changed (was a component popup) — dispose and recreate.
356
+ this.close(id);
357
+ }
358
+ this.zoneHelper.runOutsideAngular(() => {
359
+ const element = buildContentElement(options.content, options.className);
360
+ const overlay = new Overlay({
361
+ element,
362
+ position: options.position,
363
+ positioning: options.positioning ?? 'bottom-center',
364
+ offset: options.offset ?? [0, 0],
365
+ autoPan: options.autoPan ?? false,
366
+ });
367
+ map.addOverlay(overlay);
368
+ const dispose = () => {
369
+ this.zoneHelper.runOutsideAngular(() => {
370
+ map.removeOverlay(overlay);
371
+ });
372
+ };
373
+ this.popups.set(id, { id, overlay, dispose });
374
+ });
375
+ }
376
+ createOrUpdateComponent(id, options, map) {
377
+ // Always recreate component popups on a second call — simpler and safer than
378
+ // trying to re-bind inputs on an already-created ref.
379
+ if (this.popups.has(id)) {
380
+ this.close(id);
381
+ }
382
+ this.zoneHelper.runOutsideAngular(() => {
383
+ const hostElement = document.createElement('div');
384
+ if (options.className)
385
+ hostElement.className = options.className;
386
+ const ref = createComponent(options.component, {
387
+ environmentInjector: this.envInjector,
388
+ elementInjector: options.injector,
389
+ hostElement,
390
+ bindings: options.bindings,
391
+ directives: options.directives?.map((d) => typeof d === 'function' ? d : { type: d.type, bindings: d.bindings ?? [] }),
392
+ });
393
+ this.appRef.attachView(ref.hostView);
394
+ const overlay = new Overlay({
395
+ element: hostElement,
396
+ position: options.position,
397
+ positioning: options.positioning ?? 'bottom-center',
398
+ offset: options.offset ?? [0, 0],
399
+ autoPan: options.autoPan ?? false,
400
+ });
401
+ map.addOverlay(overlay);
402
+ const dispose = () => {
403
+ this.zoneHelper.runOutsideAngular(() => {
404
+ map.removeOverlay(overlay);
405
+ this.appRef.detachView(ref.hostView);
406
+ ref.destroy();
407
+ });
408
+ };
409
+ this.popups.set(id, { id, overlay, componentRef: ref, dispose });
410
+ });
8
411
  }
9
- hidePopup(id) { }
10
- hideAllPopups() { }
11
412
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlPopupService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
12
413
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlPopupService });
13
414
  }
14
415
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: OlPopupService, decorators: [{
15
416
  type: Injectable
16
- }] });
417
+ }], ctorParameters: () => [] });
418
+ // -----------------------------------------------------------------------------
419
+ // Helpers
420
+ // -----------------------------------------------------------------------------
421
+ function generateId(prefix) {
422
+ return `${prefix}-${Math.random().toString(36).slice(2, 10)}`;
423
+ }
424
+ function buildContentElement(content, className) {
425
+ const wrapper = document.createElement('div');
426
+ if (className)
427
+ wrapper.className = className;
428
+ replaceElementContent(wrapper, content);
429
+ return wrapper;
430
+ }
431
+ function replaceElementContent(element, content) {
432
+ while (element.firstChild)
433
+ element.removeChild(element.firstChild);
434
+ if (typeof content === 'string') {
435
+ element.textContent = content;
436
+ }
437
+ else {
438
+ element.appendChild(content);
439
+ }
440
+ }
441
+ function applyOverlayConfig(overlay, config) {
442
+ if (config.positioning)
443
+ overlay.setPositioning(config.positioning);
444
+ if (config.offset)
445
+ overlay.setOffset(config.offset);
446
+ }
17
447
 
18
448
  function withOverlays() {
19
449
  return { kind: 'overlays', providers: [OlPopupService] };
@@ -28,4 +458,4 @@ function provideOverlays() {
28
458
  * Generated bundle index. Do not edit.
29
459
  */
30
460
 
31
- export { OlPopupService, provideOverlays, withOverlays };
461
+ export { OlPopupComponent, OlPopupService, OlTooltipDirective, provideOverlays, withOverlays };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@angular-helpers/openlayers",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Modern Angular wrapper for OpenLayers with modular architecture, standalone components, and hybrid template/programmatic API",
5
5
  "homepage": "https://gaspar1992.github.io/angular-helpers/docs/openlayers",
6
6
  "repository": {
@@ -30,8 +30,8 @@
30
30
  "access": "public"
31
31
  },
32
32
  "peerDependencies": {
33
- "@angular/core": "^21.0.0",
34
33
  "@angular/common": "^21.0.0",
34
+ "@angular/core": "^21.0.0",
35
35
  "ol": "^10.0.0"
36
36
  },
37
37
  "peerDependenciesMeta": {
@@ -1,29 +1,214 @@
1
+ import * as _angular_core from '@angular/core';
2
+ import { Type, Binding, Injector } from '@angular/core';
1
3
  import { Coordinate, OlFeature } from '@angular-helpers/openlayers/core';
2
- import * as i0 from '@angular/core';
3
4
 
4
- type OverlayPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'top-center' | 'bottom-center' | 'left-center' | 'right-center';
5
+ /**
6
+ * Anchor of the overlay element relative to its `position` coordinate.
7
+ * Mirrors the values accepted by `ol/Overlay#setPositioning`.
8
+ */
9
+ type OverlayPositioning = 'bottom-left' | 'bottom-center' | 'bottom-right' | 'center-left' | 'center-center' | 'center-right' | 'top-left' | 'top-center' | 'top-right';
10
+ /**
11
+ * Common positioning options shared by every popup mode.
12
+ */
5
13
  interface OverlayConfig {
6
- position?: OverlayPosition;
14
+ positioning?: OverlayPositioning;
7
15
  offset?: [number, number];
16
+ className?: string;
17
+ autoPan?: boolean;
8
18
  }
9
- interface PopupOptions {
19
+ /**
20
+ * Options for `OlPopupService.open()` — string / HTMLElement content.
21
+ */
22
+ interface PopupOpenOptions extends OverlayConfig {
23
+ /** Unique id for the popup. Generated when omitted. */
10
24
  id?: string;
25
+ /** Map coordinate where the popup is anchored. */
26
+ position: Coordinate;
27
+ /**
28
+ * Popup content. A `string` is rendered as text via `textContent` (no HTML parsing).
29
+ * Pass an `HTMLElement` when richer DOM is required.
30
+ */
11
31
  content: string | HTMLElement;
32
+ }
33
+ /**
34
+ * Options for `OlPopupService.openComponent()` — dynamic Angular component content.
35
+ *
36
+ * Internally uses `createComponent + hostElement` (Angular 16.2+) so consumers can wire
37
+ * `inputBinding` / `outputBinding` / `twoWayBinding` declaratively.
38
+ */
39
+ interface PopupComponentOptions<C> extends OverlayConfig {
40
+ id?: string;
12
41
  position: Coordinate;
42
+ /** The component class to instantiate. */
43
+ component: Type<C>;
44
+ /** Bindings created via `inputBinding`, `outputBinding`, `twoWayBinding`. */
45
+ bindings?: Binding[];
46
+ /** Optional host directives applied to the dynamically-created component. */
47
+ directives?: ReadonlyArray<Type<unknown> | {
48
+ readonly type: Type<unknown>;
49
+ readonly bindings?: Binding[];
50
+ }>;
51
+ /** Optional element injector for the component (defaults to the service's injector). */
52
+ injector?: Injector;
53
+ }
54
+ /**
55
+ * Handle returned by `OlPopupService.open()` and `openComponent()`.
56
+ * Use it to close the popup imperatively without going through the service.
57
+ */
58
+ interface PopupHandle {
59
+ readonly id: string;
60
+ close(): void;
61
+ }
62
+ /**
63
+ * @deprecated Use {@link PopupOpenOptions} instead. Kept for backwards compatibility
64
+ * with the v0.2.x stub API and removed in a future major version.
65
+ */
66
+ type PopupOptions = PopupOpenOptions & {
67
+ /** No-op flag retained for source compatibility. */
13
68
  autoClose?: boolean;
69
+ /** Whether to render the default close button (deprecated, prefer `<ol-popup>`). */
14
70
  closeButton?: boolean;
71
+ };
72
+ /**
73
+ * @deprecated Use {@link OverlayPositioning} instead.
74
+ */
75
+ type OverlayPosition = OverlayPositioning;
76
+
77
+ /**
78
+ * Declarative map popup that projects arbitrary Angular content via `<ng-content>`.
79
+ *
80
+ * The component's host element is used directly as the `ol/Overlay`'s element, so
81
+ * projected children stay inside Angular's component tree and benefit from change
82
+ * detection without any extra plumbing.
83
+ *
84
+ * @example
85
+ * ```html
86
+ * <ol-popup
87
+ * [position]="selectedCoord()"
88
+ * [closeButton]="true"
89
+ * [autoPan]="true"
90
+ * (closed)="clearSelection()"
91
+ * >
92
+ * <h3>{{ selected()?.name }}</h3>
93
+ * <p>{{ selected()?.description }}</p>
94
+ * </ol-popup>
95
+ * ```
96
+ */
97
+ declare class OlPopupComponent {
98
+ private readonly elementRef;
99
+ private readonly mapService;
100
+ private readonly zoneHelper;
101
+ /** Map coordinate where the popup is anchored. `null` hides the popup. */
102
+ readonly position: _angular_core.InputSignal<Coordinate>;
103
+ /** Pixel offset relative to `position`. */
104
+ readonly offset: _angular_core.InputSignal<[number, number]>;
105
+ /** Anchor of the popup element relative to `position`. */
106
+ readonly positioning: _angular_core.InputSignal<OverlayPositioning>;
107
+ /** Whether the map auto-pans to keep the popup in view. */
108
+ readonly autoPan: _angular_core.InputSignal<boolean>;
109
+ /** Whether to render the default close button. */
110
+ readonly closeButton: _angular_core.InputSignal<boolean>;
111
+ /** Emitted when the popup transitions from visible to hidden (close button or `position` set to null). */
112
+ readonly closed: _angular_core.OutputEmitterRef<void>;
113
+ private overlay;
114
+ private currentMap;
115
+ private wasVisible;
116
+ constructor();
117
+ /** @internal */
118
+ onCloseClick(): void;
119
+ private applyState;
120
+ private dispose;
121
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<OlPopupComponent, never>;
122
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<OlPopupComponent, "ol-popup", never, { "position": { "alias": "position"; "required": false; "isSignal": true; }; "offset": { "alias": "offset"; "required": false; "isSignal": true; }; "positioning": { "alias": "positioning"; "required": false; "isSignal": true; }; "autoPan": { "alias": "autoPan"; "required": false; "isSignal": true; }; "closeButton": { "alias": "closeButton"; "required": false; "isSignal": true; }; }, { "closed": "closed"; }, never, ["*"], true, never>;
123
+ }
124
+
125
+ /**
126
+ * Shows a floating tooltip whose text is read from a feature property when the
127
+ * pointer hovers over it. The tooltip element is appended to the map viewport
128
+ * and styled minimally; consumers can override via the `.ol-tooltip` CSS hook.
129
+ *
130
+ * @example
131
+ * ```html
132
+ * <ol-vector-layer
133
+ * id="cities"
134
+ * [features]="cities()"
135
+ * [olTooltip]="'name'"
136
+ * />
137
+ * ```
138
+ *
139
+ * Apply `[olTooltipLayer]` to limit detection to a single layer (matches the
140
+ * value of the layer's `id` property). Without it the tooltip reacts to any
141
+ * feature found at the cursor.
142
+ */
143
+ declare class OlTooltipDirective {
144
+ private readonly mapService;
145
+ private readonly zoneHelper;
146
+ /** Property key to read from the hovered feature. */
147
+ readonly olTooltip: _angular_core.InputSignal<string>;
148
+ /** Optional layer id; when set, only features on that layer trigger the tooltip. */
149
+ readonly olTooltipLayer: _angular_core.InputSignal<string>;
150
+ private element;
151
+ private listener;
152
+ private currentMap;
153
+ constructor();
154
+ private handlePointerMove;
155
+ private dispose;
156
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<OlTooltipDirective, never>;
157
+ static ɵdir: _angular_core.ɵɵDirectiveDeclaration<OlTooltipDirective, "[olTooltip]", never, { "olTooltip": { "alias": "olTooltip"; "required": true; "isSignal": true; }; "olTooltipLayer": { "alias": "olTooltipLayer"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
15
158
  }
16
159
 
160
+ /**
161
+ * Manages OpenLayers popup overlays anchored to map coordinates.
162
+ *
163
+ * Three content modes:
164
+ * - `open()` — string text or `HTMLElement`
165
+ * - `openComponent()` — dynamic Angular component via `createComponent + hostElement`
166
+ * - The `<ol-popup>` component delegates `open()` internally for declarative usage.
167
+ *
168
+ * All calls are idempotent by `id`. Calls made before the underlying `ol/Map` is ready
169
+ * are queued and replayed in order once `OlMapService.onReady` fires.
170
+ */
17
171
  declare class OlPopupService {
18
- showPopup(options: PopupOptions): string;
19
- hidePopup(id: string): void;
20
- hideAllPopups(): void;
21
- static ɵfac: i0.ɵɵFactoryDeclaration<OlPopupService, never>;
22
- static ɵprov: i0.ɵɵInjectableDeclaration<OlPopupService>;
172
+ private readonly mapService;
173
+ private readonly zoneHelper;
174
+ private readonly envInjector;
175
+ private readonly appRef;
176
+ private readonly popups;
177
+ private readonly pending;
178
+ private flushSubscribed;
179
+ constructor();
180
+ /**
181
+ * Open a popup with `string` or `HTMLElement` content.
182
+ *
183
+ * Idempotent by `id`: a second call with the same id updates `position` and
184
+ * `content` of the existing popup instead of creating a duplicate.
185
+ */
186
+ open(options: PopupOpenOptions): PopupHandle;
187
+ /**
188
+ * Open a popup whose content is a dynamically-instantiated Angular component.
189
+ *
190
+ * Uses `createComponent({ environmentInjector, hostElement, bindings, directives })`
191
+ * (Angular 16.2+) and attaches the host view to `ApplicationRef` so change detection
192
+ * reaches the rendered component.
193
+ *
194
+ * Idempotent by `id`: a previous `ComponentRef` registered under the same id is
195
+ * destroyed before the new one is created.
196
+ */
197
+ openComponent<C>(options: PopupComponentOptions<C>): PopupHandle;
198
+ /** Close and dispose a single popup by id. No-op when the id is unknown. */
199
+ close(id: string): void;
200
+ /** Close every managed popup. */
201
+ closeAll(): void;
202
+ private queue;
203
+ private flushPending;
204
+ private createOrUpdate;
205
+ private createOrUpdateComponent;
206
+ static ɵfac: _angular_core.ɵɵFactoryDeclaration<OlPopupService, never>;
207
+ static ɵprov: _angular_core.ɵɵInjectableDeclaration<OlPopupService>;
23
208
  }
24
209
 
25
210
  declare function withOverlays(): OlFeature<'overlays'>;
26
211
  declare function provideOverlays(): OlFeature<'overlays'>;
27
212
 
28
- export { OlPopupService, provideOverlays, withOverlays };
29
- export type { OverlayConfig, OverlayPosition, PopupOptions };
213
+ export { OlPopupComponent, OlPopupService, OlTooltipDirective, provideOverlays, withOverlays };
214
+ export type { OverlayConfig, OverlayPosition, OverlayPositioning, PopupComponentOptions, PopupHandle, PopupOpenOptions, PopupOptions };