@kaufmann-lab/ui 0.1.16 → 0.1.18

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,1637 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, input, output, linkedSignal, Component, signal, computed, booleanAttribute, inject, EnvironmentInjector, DestroyRef, ElementRef, viewChild, afterNextRender, afterRenderEffect, createComponent, ViewEncapsulation } from '@angular/core';
3
+ import { Icon, Button, Dropdown, DropdownPanel, TooltipDirective, Spinner, Text } from '@kaufmann-lab/ui/core';
4
+ import { handleArrowKeyNavigation } from '@kaufmann-lab/ui/shared';
5
+ import { DOCUMENT } from '@angular/common';
6
+ import { Map, AttributionControl, LngLatBounds, Marker } from 'mapbox-gl/esm';
7
+
8
+ const MAP_CONFIG = new InjectionToken('MAP_CONFIG');
9
+ /**
10
+ * `provide*` idiomático (mismo patrón que `provideHttpClient`/`provideRouter`)
11
+ * para inyectar una sola vez en `app.config.ts` de cada app consumidora:
12
+ *
13
+ * providers: [provideMapConfig({ accessToken: 'pk.xxx' })]
14
+ */
15
+ function provideMapConfig(config) {
16
+ return { provide: MAP_CONFIG, useValue: config };
17
+ }
18
+
19
+ const STYLE_BY_THEME = {
20
+ light: 'mapbox://styles/mapbox/light-v11',
21
+ dark: 'mapbox://styles/mapbox/dark-v11',
22
+ streets: 'mapbox://styles/mapbox/streets-v12',
23
+ satellite: 'mapbox://styles/mapbox/satellite-streets-v12',
24
+ outdoors: 'mapbox://styles/mapbox/outdoors-v12',
25
+ };
26
+ /** Orden fijo en que `MapThemePicker` lista las 5 opciones — predecible
27
+ * (siempre el mismo orden en toda pantalla que use el picker), no importa
28
+ * cuál esté activa. */
29
+ const THEME_ORDER = ['light', 'dark', 'streets', 'satellite', 'outdoors'];
30
+ /** Nombre en español de cada tema, para el `aria-label`/label del control
31
+ * `mapStyle` y las opciones del picker — anuncia qué tema rige HOY o qué
32
+ * tema se está por elegir, no solo "otro tema distinto". */
33
+ const THEME_LABEL = {
34
+ light: 'Claro',
35
+ dark: 'Oscuro',
36
+ streets: 'Calles',
37
+ satellite: 'Satélite',
38
+ outdoors: 'Relieve',
39
+ };
40
+
41
+ /**
42
+ * Contenido del panel flotante que abre el control `mapStyle` de
43
+ * `kf-map-controls` — vive dentro de un `kf-dropdown-panel` (reusa el
44
+ * overlay/posicionamiento/click-afuera/Escape de `kf-dropdown`, no
45
+ * construye ninguno propio). Lista las 5 opciones de `MapTheme` con un
46
+ * swatch de preview y marca cuál está activa — reemplaza el ciclado de un
47
+ * solo click que tenía el botón `mapStyle` antes: elegir a la vista es más
48
+ * predecible que ir adivinando cuántos clicks faltan para el tema que se
49
+ * quiere.
50
+ *
51
+ * Presentacional puro (sin estado propio de NEGOCIO): `MapControls` decide
52
+ * cuándo mostrarlo y qué hacer con la selección.
53
+ *
54
+ * <kf-map-theme-picker [active]="activeTheme()" (themeSelect)="onSelect($event)" />
55
+ */
56
+ class MapThemePicker {
57
+ active = input.required(/* @ts-ignore */
58
+ ...(ngDevMode ? [{ debugName: "active" }] : /* istanbul ignore next */ []));
59
+ /** No `select` a secas: colisiona con el evento DOM nativo del mismo
60
+ * nombre (`@angular-eslint/no-output-native`). */
61
+ themeSelect = output();
62
+ themes = THEME_ORDER;
63
+ themeLabel = THEME_LABEL;
64
+ themeOptions = this.themes.map((theme) => ({ value: theme }));
65
+ /**
66
+ * Tema con foco de teclado ROVING (punto 4, a11y) — distinto de `active`
67
+ * (el tema realmente APLICADO). Arranca en `active()` (mismo criterio de
68
+ * cualquier roving-tabindex real: el foco inicial cae sobre la opción ya
69
+ * seleccionada, no siempre la primera) pero navegar con flechas SOLO mueve
70
+ * este puntero, nunca aplica el tema.
71
+ *
72
+ * Por qué no alcanza con pasarle `themeSelect.emit` directo a
73
+ * `handleArrowKeyNavigation` (como sí hace `kf-tabs`, ver su JSDoc): ahí
74
+ * "seleccionar" y "mover el foco" son lo mismo (WAI-ARIA Tabs Pattern,
75
+ * selection-follows-focus). Acá NO — este panel es un menú
76
+ * (`role="menu"`/`"menuitemradio"`) dentro de un `kf-dropdown` que SE
77
+ * CIERRA al elegir (ver `onThemeSelect()` en `MapControls`): si cada
78
+ * flecha emitiera `themeSelect`, la primera flecha cerraría el panel de un
79
+ * solo golpe y sería imposible recorrer más de una opción por teclado.
80
+ * Activar sigue siendo Enter/Space/click sobre el `<button>` — nativo del
81
+ * propio elemento, no necesita wiring propio acá.
82
+ *
83
+ * `linkedSignal` (no `signal` a secas): un `input.required()` no tiene
84
+ * valor todavía en el initializer de un campo de clase — `linkedSignal`
85
+ * es la forma real de arrancar un signal escribible en el valor de OTRO
86
+ * signal (acá, `active()`) y a la vez poder pisarlo después a mano (acá,
87
+ * desde `onKeydown()`) sin que ese valor inicial se pierda ni haga falta
88
+ * un `effect()` aparte.
89
+ */
90
+ focusedTheme = linkedSignal(() => this.active(), /* @ts-ignore */
91
+ ...(ngDevMode ? [{ debugName: "focusedTheme" }] : /* istanbul ignore next */ []));
92
+ onKeydown(event) {
93
+ handleArrowKeyNavigation(event, this.themeOptions, this.focusedTheme(), 'kf-map-theme-picker-item-', (value) => this.focusedTheme.set(value), 'vertical');
94
+ }
95
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: MapThemePicker, deps: [], target: i0.ɵɵFactoryTarget.Component });
96
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.0", type: MapThemePicker, isStandalone: true, selector: "kf-map-theme-picker", inputs: { active: { classPropertyName: "active", publicName: "active", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { themeSelect: "themeSelect" }, ngImport: i0, template: "<div class=\"kf-map-theme-picker\" role=\"menu\" aria-label=\"Tema del mapa\">\n @for (theme of themes; track theme) {\n <button\n type=\"button\"\n class=\"kf-map-theme-picker__item\"\n [class.kf-map-theme-picker__item--active]=\"theme === active()\"\n role=\"menuitemradio\"\n [attr.aria-checked]=\"theme === active()\"\n [id]=\"'kf-map-theme-picker-item-' + theme\"\n [attr.tabindex]=\"theme === focusedTheme() ? 0 : -1\"\n (click)=\"themeSelect.emit(theme)\"\n (keydown)=\"onKeydown($event)\"\n >\n <span class=\"kf-map-theme-picker__swatch\" [class]=\"'kf-map-theme-picker__swatch--' + theme\">\n @if (theme === active()) {\n <span class=\"kf-map-theme-picker__check\">\n <kf-icon name=\"check\" size=\"sm\" />\n </span>\n }\n </span>\n <span class=\"kf-map-theme-picker__label\">{{ themeLabel[theme] }}</span>\n </button>\n }\n</div>\n", styles: [":host{display:block}.kf-map-theme-picker{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--kf-space-sm);padding:var(--kf-space-sm);width:216px}.kf-map-theme-picker__item{display:flex;flex-direction:column;align-items:center;gap:6px;padding:0;border:none;background:transparent;font:inherit;cursor:pointer;border-radius:var(--kf-radius-sm)}.kf-map-theme-picker__item:focus-visible{outline:none;box-shadow:var(--kf-shadow-focus)}.kf-map-theme-picker__label{font-size:.75rem;font-weight:500;color:var(--kf-color-text)}.kf-map-theme-picker__item--active .kf-map-theme-picker__label{font-weight:700;color:var(--kf-color-secondary)}.kf-map-theme-picker__swatch{position:relative;display:block;width:100%;height:64px;border-radius:var(--kf-radius-sm);border:2px solid transparent;box-shadow:inset 0 0 0 1px var(--kf-color-border);transition:transform .12s ease,box-shadow .12s ease}.kf-map-theme-picker__item:hover .kf-map-theme-picker__swatch{transform:translateY(-1px);box-shadow:inset 0 0 0 1px var(--kf-color-border),var(--kf-shadow-sm)}.kf-map-theme-picker__item--active .kf-map-theme-picker__swatch{border-color:var(--kf-color-secondary);box-shadow:none}@media(prefers-reduced-motion:reduce){.kf-map-theme-picker__swatch{transition:none}}.kf-map-theme-picker__check{position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:var(--kf-color-secondary);color:var(--kf-color-white);display:flex;align-items:center;justify-content:center;box-shadow:var(--kf-shadow-sm)}.kf-map-theme-picker__swatch--light{background:linear-gradient(90deg,transparent 46%,#cbd5e1 46% 54%,transparent 54%),linear-gradient(#f8fafc,#f8fafc)}.kf-map-theme-picker__swatch--dark{background:linear-gradient(90deg,transparent 46%,#475569 46% 54%,transparent 54%),linear-gradient(#1e293b,#0f172a)}.kf-map-theme-picker__swatch--streets{background:linear-gradient(90deg,transparent 30%,#fca5a5 30% 36%,transparent 36%),linear-gradient(0deg,transparent 60%,#fde68a 60% 68%,transparent 68%),linear-gradient(#fef9ec,#fef3c7)}.kf-map-theme-picker__swatch--satellite{background:radial-gradient(circle at 30% 35%,#4d7c3a 0 22%,transparent 23%),radial-gradient(circle at 70% 65%,#3f6b30 0 18%,transparent 19%),linear-gradient(#2d4a24,#1f3a1a)}.kf-map-theme-picker__swatch--outdoors{background:linear-gradient(90deg,transparent 55%,#86c67c 55% 65%,transparent 65%),linear-gradient(#eafbe7,#cdeec4)}\n"], dependencies: [{ kind: "component", type: Icon, selector: "kf-icon", inputs: ["name", "size", "rotate", "filled"] }] });
97
+ }
98
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: MapThemePicker, decorators: [{
99
+ type: Component,
100
+ args: [{ selector: 'kf-map-theme-picker', imports: [Icon], template: "<div class=\"kf-map-theme-picker\" role=\"menu\" aria-label=\"Tema del mapa\">\n @for (theme of themes; track theme) {\n <button\n type=\"button\"\n class=\"kf-map-theme-picker__item\"\n [class.kf-map-theme-picker__item--active]=\"theme === active()\"\n role=\"menuitemradio\"\n [attr.aria-checked]=\"theme === active()\"\n [id]=\"'kf-map-theme-picker-item-' + theme\"\n [attr.tabindex]=\"theme === focusedTheme() ? 0 : -1\"\n (click)=\"themeSelect.emit(theme)\"\n (keydown)=\"onKeydown($event)\"\n >\n <span class=\"kf-map-theme-picker__swatch\" [class]=\"'kf-map-theme-picker__swatch--' + theme\">\n @if (theme === active()) {\n <span class=\"kf-map-theme-picker__check\">\n <kf-icon name=\"check\" size=\"sm\" />\n </span>\n }\n </span>\n <span class=\"kf-map-theme-picker__label\">{{ themeLabel[theme] }}</span>\n </button>\n }\n</div>\n", styles: [":host{display:block}.kf-map-theme-picker{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--kf-space-sm);padding:var(--kf-space-sm);width:216px}.kf-map-theme-picker__item{display:flex;flex-direction:column;align-items:center;gap:6px;padding:0;border:none;background:transparent;font:inherit;cursor:pointer;border-radius:var(--kf-radius-sm)}.kf-map-theme-picker__item:focus-visible{outline:none;box-shadow:var(--kf-shadow-focus)}.kf-map-theme-picker__label{font-size:.75rem;font-weight:500;color:var(--kf-color-text)}.kf-map-theme-picker__item--active .kf-map-theme-picker__label{font-weight:700;color:var(--kf-color-secondary)}.kf-map-theme-picker__swatch{position:relative;display:block;width:100%;height:64px;border-radius:var(--kf-radius-sm);border:2px solid transparent;box-shadow:inset 0 0 0 1px var(--kf-color-border);transition:transform .12s ease,box-shadow .12s ease}.kf-map-theme-picker__item:hover .kf-map-theme-picker__swatch{transform:translateY(-1px);box-shadow:inset 0 0 0 1px var(--kf-color-border),var(--kf-shadow-sm)}.kf-map-theme-picker__item--active .kf-map-theme-picker__swatch{border-color:var(--kf-color-secondary);box-shadow:none}@media(prefers-reduced-motion:reduce){.kf-map-theme-picker__swatch{transition:none}}.kf-map-theme-picker__check{position:absolute;top:-6px;right:-6px;width:20px;height:20px;border-radius:50%;background:var(--kf-color-secondary);color:var(--kf-color-white);display:flex;align-items:center;justify-content:center;box-shadow:var(--kf-shadow-sm)}.kf-map-theme-picker__swatch--light{background:linear-gradient(90deg,transparent 46%,#cbd5e1 46% 54%,transparent 54%),linear-gradient(#f8fafc,#f8fafc)}.kf-map-theme-picker__swatch--dark{background:linear-gradient(90deg,transparent 46%,#475569 46% 54%,transparent 54%),linear-gradient(#1e293b,#0f172a)}.kf-map-theme-picker__swatch--streets{background:linear-gradient(90deg,transparent 30%,#fca5a5 30% 36%,transparent 36%),linear-gradient(0deg,transparent 60%,#fde68a 60% 68%,transparent 68%),linear-gradient(#fef9ec,#fef3c7)}.kf-map-theme-picker__swatch--satellite{background:radial-gradient(circle at 30% 35%,#4d7c3a 0 22%,transparent 23%),radial-gradient(circle at 70% 65%,#3f6b30 0 18%,transparent 19%),linear-gradient(#2d4a24,#1f3a1a)}.kf-map-theme-picker__swatch--outdoors{background:linear-gradient(90deg,transparent 55%,#86c67c 55% 65%,transparent 65%),linear-gradient(#eafbe7,#cdeec4)}\n"] }]
101
+ }], propDecorators: { active: [{ type: i0.Input, args: [{ isSignal: true, alias: "active", required: true }] }], themeSelect: [{ type: i0.Output, args: ["themeSelect"] }] } });
102
+
103
+ /**
104
+ * Barra de controles flotantes de `kf-map` — pieza puramente presentacional,
105
+ * sin estado propio: todo lo que sabe se lo pasa `MapView` por input, todo
106
+ * lo que hace lo reporta por output para que `MapView` (única dueña del
107
+ * `Map` de Mapbox) decida qué hacer. Vive en la misma carpeta que `MapView`
108
+ * a propósito (mismo criterio que `DropdownItem`/`DropdownPanel` con
109
+ * `Dropdown`): el acoplamiento es real, no de nombre — no tiene sentido
110
+ * fuera de un `kf-map` que la controle.
111
+ *
112
+ * Un solo stack vertical, siempre en la esquina superior derecha del mapa
113
+ * (fijado por `MapView.css`, no acá): es donde ya vivía el `NavigationControl`
114
+ * de Mapbox que este componente reemplaza, y la atribución obligatoria de
115
+ * Mapbox (`AttributionControl`, nunca oculta) vive abajo-derecha — un solo
116
+ * stack evita que dos grupos floten en esquinas distintas y compitan por
117
+ * espacio en un mapa `size="sm"` de 200px de alto.
118
+ *
119
+ * El stack no es una lista continua de botones idénticos: cada grupo lógico
120
+ * es su propia "card" flotante (`kf-radius-md`+`kf-shadow-md`+fondo blanco,
121
+ * ver `.kf-map-controls__group` en `MapControls.css`), separadas entre sí
122
+ * por el `gap` del `:host` — mismo lenguaje ya verificado visualmente en
123
+ * `route-assistance-monorepo/design/mockups/operations-final.html`
124
+ * (`.map__ctrl-group`). Orden fijo de arriba a abajo, independiente del
125
+ * orden en el array `controls` del consumidor (para que dos pantallas que
126
+ * piden los mismos controles en distinto orden se vean IGUAL):
127
+ *
128
+ * 1. zoom — magnificación (par +/-, unido por un divisor, sin gap entre sí).
129
+ * 2. cámara — recenter + follow (posición de la cámara).
130
+ * 3. datos — cluster (densidad de los markers, eje distinto de cámara).
131
+ * 4. chrome — mapStyle + fullscreen (nada del mapa en sí).
132
+ *
133
+ * Cada grupo usa `@if` sobre AL MENOS UNO de sus controles (ej. el grupo de
134
+ * cámara aparece con solo `recenter`, solo `follow`, o ambos) — agregar o
135
+ * sacar un control nunca reacomoda ni depende de los demás grupos.
136
+ *
137
+ * Todos los botones siguen siendo `kf-button[variant=surface][shape=circle]`
138
+ * — sin tocar `Button.ts`: el fondo/sombra propios de `surface` se anulan
139
+ * SOLO dentro de un `.kf-map-controls__group` vía una custom property CSS
140
+ * (ver el comentario junto a esa clase en `MapControls.css` sobre por qué
141
+ * eso sí atraviesa la encapsulación de Angular y un selector normal no).
142
+ */
143
+ class MapControls {
144
+ controls = input([], /* @ts-ignore */
145
+ ...(ngDevMode ? [{ debugName: "controls" }] : /* istanbul ignore next */ []));
146
+ clusterActive = input(false, /* @ts-ignore */
147
+ ...(ngDevMode ? [{ debugName: "clusterActive" }] : /* istanbul ignore next */ []));
148
+ followActive = input(false, /* @ts-ignore */
149
+ ...(ngDevMode ? [{ debugName: "followActive" }] : /* istanbul ignore next */ []));
150
+ /** Sin ningún marker `live`, togglear "seguir" no tendría ningún efecto
151
+ * visible — se deshabilita en vez de dejar un botón que parece no hacer nada. */
152
+ canFollow = input(false, /* @ts-ignore */
153
+ ...(ngDevMode ? [{ debugName: "canFollow" }] : /* istanbul ignore next */ []));
154
+ isFullscreen = input(false, /* @ts-ignore */
155
+ ...(ngDevMode ? [{ debugName: "isFullscreen" }] : /* istanbul ignore next */ []));
156
+ /** Nombre en español del tema actualmente resuelto (ej. "Calles"), para
157
+ * anunciar en el `aria-label` del botón de estilo qué se está por perder,
158
+ * no solo que "algo" va a cambiar. */
159
+ styleLabel = input('', /* @ts-ignore */
160
+ ...(ngDevMode ? [{ debugName: "styleLabel" }] : /* istanbul ignore next */ []));
161
+ /** Tema resuelto HOY (`light|dark|...`) — a diferencia de `styleLabel`
162
+ * (ya traducido a texto), esto es lo que `MapThemePicker` necesita para
163
+ * marcar la opción activa. */
164
+ activeTheme = input('dark', /* @ts-ignore */
165
+ ...(ngDevMode ? [{ debugName: "activeTheme" }] : /* istanbul ignore next */ []));
166
+ zoomIn = output();
167
+ zoomOut = output();
168
+ recenter = output();
169
+ followToggle = output();
170
+ clusterToggle = output();
171
+ /** Reemplaza al viejo `styleCycle` (ciclado de un click): ahora el botón
172
+ * `mapStyle` abre `MapThemePicker` y este output reporta la elección real
173
+ * hecha ahí. */
174
+ themeSelect = output();
175
+ fullscreenToggle = output();
176
+ /** Visibilidad del panel de temas — estado puramente de UI (abierto o no),
177
+ * no de negocio: `MapView` no necesita saberlo, así que vive acá y no
178
+ * rompe el criterio de "sin estado propio" del resto de la clase (ver
179
+ * JSDoc). Se cierra solo (click afuera/Escape) vía `kf-dropdown`, y acá
180
+ * además al elegir un tema. */
181
+ mapStyleOpen = signal(false, /* @ts-ignore */
182
+ ...(ngDevMode ? [{ debugName: "mapStyleOpen" }] : /* istanbul ignore next */ []));
183
+ onThemeSelect(theme) {
184
+ this.themeSelect.emit(theme);
185
+ this.mapStyleOpen.set(false);
186
+ }
187
+ hasZoom = computed(() => this.controls().includes('zoom'), /* @ts-ignore */
188
+ ...(ngDevMode ? [{ debugName: "hasZoom" }] : /* istanbul ignore next */ []));
189
+ hasRecenter = computed(() => this.controls().includes('recenter'), /* @ts-ignore */
190
+ ...(ngDevMode ? [{ debugName: "hasRecenter" }] : /* istanbul ignore next */ []));
191
+ hasFollow = computed(() => this.controls().includes('follow'), /* @ts-ignore */
192
+ ...(ngDevMode ? [{ debugName: "hasFollow" }] : /* istanbul ignore next */ []));
193
+ hasCluster = computed(() => this.controls().includes('cluster'), /* @ts-ignore */
194
+ ...(ngDevMode ? [{ debugName: "hasCluster" }] : /* istanbul ignore next */ []));
195
+ hasMapStyle = computed(() => this.controls().includes('mapStyle'), /* @ts-ignore */
196
+ ...(ngDevMode ? [{ debugName: "hasMapStyle" }] : /* istanbul ignore next */ []));
197
+ hasFullscreen = computed(() => this.controls().includes('fullscreen'), /* @ts-ignore */
198
+ ...(ngDevMode ? [{ debugName: "hasFullscreen" }] : /* istanbul ignore next */ []));
199
+ /** El grupo "cámara" (card) aparece con solo `recenter`, solo `follow`, o
200
+ * ambos — ver JSDoc de la clase sobre por qué cada `@if` de grupo cubre
201
+ * "al menos uno de sus controles" en vez de un control puntual. */
202
+ hasCamera = computed(() => this.hasRecenter() || this.hasFollow(), /* @ts-ignore */
203
+ ...(ngDevMode ? [{ debugName: "hasCamera" }] : /* istanbul ignore next */ []));
204
+ hasChrome = computed(() => this.hasMapStyle() || this.hasFullscreen(), /* @ts-ignore */
205
+ ...(ngDevMode ? [{ debugName: "hasChrome" }] : /* istanbul ignore next */ []));
206
+ /**
207
+ * Texto corto que revela cada botón en hover/foco vía `[kfTooltip]` (ver
208
+ * `MapControls.html` — la librería ya resuelve este patrón con
209
+ * `TooltipDirective`, no con CSS a mano) — la misma acción que ya describe
210
+ * `ariaLabel`, condensada para caber en una píldora en vez de leerse como
211
+ * oración completa. No reemplaza `ariaLabel` (que sigue siendo la fuente
212
+ * real para lectores de pantalla): esto es SOLO visual, para quien puede
213
+ * ver el mapa pero no sabe qué hace un ícono suelto.
214
+ */
215
+ followLabel = computed(() => this.followActive() ? 'Dejar de seguir' : 'Seguir', /* @ts-ignore */
216
+ ...(ngDevMode ? [{ debugName: "followLabel" }] : /* istanbul ignore next */ []));
217
+ clusterLabel = computed(() => this.clusterActive() ? 'Desagrupar' : 'Agrupar', /* @ts-ignore */
218
+ ...(ngDevMode ? [{ debugName: "clusterLabel" }] : /* istanbul ignore next */ []));
219
+ mapStyleLabel = computed(() => `Tema: ${this.styleLabel()}`, /* @ts-ignore */
220
+ ...(ngDevMode ? [{ debugName: "mapStyleLabel" }] : /* istanbul ignore next */ []));
221
+ fullscreenLabel = computed(() => this.isFullscreen() ? 'Salir de pantalla completa' : 'Pantalla completa', /* @ts-ignore */
222
+ ...(ngDevMode ? [{ debugName: "fullscreenLabel" }] : /* istanbul ignore next */ []));
223
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: MapControls, deps: [], target: i0.ɵɵFactoryTarget.Component });
224
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.0", type: MapControls, isStandalone: true, selector: "kf-map-controls", inputs: { controls: { classPropertyName: "controls", publicName: "controls", isSignal: true, isRequired: false, transformFunction: null }, clusterActive: { classPropertyName: "clusterActive", publicName: "clusterActive", isSignal: true, isRequired: false, transformFunction: null }, followActive: { classPropertyName: "followActive", publicName: "followActive", isSignal: true, isRequired: false, transformFunction: null }, canFollow: { classPropertyName: "canFollow", publicName: "canFollow", isSignal: true, isRequired: false, transformFunction: null }, isFullscreen: { classPropertyName: "isFullscreen", publicName: "isFullscreen", isSignal: true, isRequired: false, transformFunction: null }, styleLabel: { classPropertyName: "styleLabel", publicName: "styleLabel", isSignal: true, isRequired: false, transformFunction: null }, activeTheme: { classPropertyName: "activeTheme", publicName: "activeTheme", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { zoomIn: "zoomIn", zoomOut: "zoomOut", recenter: "recenter", followToggle: "followToggle", clusterToggle: "clusterToggle", themeSelect: "themeSelect", fullscreenToggle: "fullscreenToggle" }, ngImport: i0, template: "@if (hasZoom()) {\n <div class=\"kf-map-controls__group kf-map-controls__group--zoom\">\n <div class=\"kf-map-controls__item\" kfTooltip=\"Acercar\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"plus\"\n ariaLabel=\"Acercar\"\n (click)=\"zoomIn.emit()\"\n />\n </div>\n <div class=\"kf-map-controls__item\" kfTooltip=\"Alejar\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"minus\"\n ariaLabel=\"Alejar\"\n (click)=\"zoomOut.emit()\"\n />\n </div>\n </div>\n}\n@if (hasCamera()) {\n <div class=\"kf-map-controls__group\">\n @if (hasRecenter()) {\n <div class=\"kf-map-controls__item\" kfTooltip=\"Centrar mapa\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"crosshair\"\n ariaLabel=\"Volver a encuadrar el mapa\"\n (click)=\"recenter.emit()\"\n />\n </div>\n }\n @if (hasFollow()) {\n <div class=\"kf-map-controls__item\" [kfTooltip]=\"followLabel()\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"navigation\"\n [ariaLabel]=\"followActive() ? 'Dejar de seguir' : 'Seguir posici\u00F3n'\"\n [ariaPressed]=\"followActive()\"\n [disabled]=\"!canFollow()\"\n (click)=\"followToggle.emit()\"\n />\n </div>\n }\n </div>\n}\n@if (hasCluster()) {\n <div class=\"kf-map-controls__group\">\n <div class=\"kf-map-controls__item\" [kfTooltip]=\"clusterLabel()\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"grid\"\n [ariaLabel]=\"clusterActive() ? 'Desagrupar marcadores' : 'Agrupar marcadores cercanos'\"\n [ariaPressed]=\"clusterActive()\"\n (click)=\"clusterToggle.emit()\"\n />\n </div>\n </div>\n}\n@if (hasChrome()) {\n <div class=\"kf-map-controls__group\">\n @if (hasMapStyle()) {\n <kf-dropdown\n class=\"kf-map-controls__item\"\n [kfTooltip]=\"mapStyleLabel()\"\n kfTooltipPosition=\"left\"\n [(open)]=\"mapStyleOpen\"\n >\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"layers\"\n [ariaLabel]=\"'Cambiar estilo de mapa (actual: ' + styleLabel() + ')'\"\n [ariaExpanded]=\"mapStyleOpen()\"\n (click)=\"mapStyleOpen.set(!mapStyleOpen())\"\n />\n <kf-dropdown-panel>\n <kf-map-theme-picker [active]=\"activeTheme()\" (themeSelect)=\"onThemeSelect($event)\" />\n </kf-dropdown-panel>\n </kf-dropdown>\n }\n @if (hasFullscreen()) {\n <div class=\"kf-map-controls__item\" [kfTooltip]=\"fullscreenLabel()\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n [icon]=\"isFullscreen() ? 'minimize' : 'maximize'\"\n [ariaLabel]=\"isFullscreen() ? 'Salir de pantalla completa' : 'Pantalla completa'\"\n [ariaPressed]=\"isFullscreen()\"\n (click)=\"fullscreenToggle.emit()\"\n />\n </div>\n }\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;align-items:flex-end;gap:var(--kf-space-sm)}.kf-map-controls__item{position:relative;display:flex}.kf-map-controls__group{display:flex;flex-direction:column;gap:2px;background:var(--kf-color-surface);border-radius:var(--kf-radius-md);box-shadow:var(--kf-shadow-md);padding:3px}.kf-map-controls__group kf-button{--kf-color-surface: transparent;--kf-color-border: transparent;--kf-shadow-md: none}.kf-map-controls__group--zoom{gap:0}.kf-map-controls__group--zoom .kf-map-controls__item+.kf-map-controls__item{border-top:1px solid var(--kf-color-border)}\n"], dependencies: [{ kind: "component", type: Button, selector: "kf-button", inputs: ["variant", "size", "shape", "disabled", "type", "icon", "block", "loading", "ariaLabel", "ariaExpanded", "ariaPressed"] }, { kind: "component", type: Dropdown, selector: "kf-dropdown", inputs: ["open", "anchor"], outputs: ["openChange"] }, { kind: "component", type: DropdownPanel, selector: "kf-dropdown-panel", inputs: ["variant"] }, { kind: "component", type: MapThemePicker, selector: "kf-map-theme-picker", inputs: ["active"], outputs: ["themeSelect"] }, { kind: "directive", type: TooltipDirective, selector: "[kfTooltip]", inputs: ["kfTooltip", "kfTooltipPosition"] }] });
225
+ }
226
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: MapControls, decorators: [{
227
+ type: Component,
228
+ args: [{ selector: 'kf-map-controls', imports: [Button, Dropdown, DropdownPanel, MapThemePicker, TooltipDirective], template: "@if (hasZoom()) {\n <div class=\"kf-map-controls__group kf-map-controls__group--zoom\">\n <div class=\"kf-map-controls__item\" kfTooltip=\"Acercar\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"plus\"\n ariaLabel=\"Acercar\"\n (click)=\"zoomIn.emit()\"\n />\n </div>\n <div class=\"kf-map-controls__item\" kfTooltip=\"Alejar\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"minus\"\n ariaLabel=\"Alejar\"\n (click)=\"zoomOut.emit()\"\n />\n </div>\n </div>\n}\n@if (hasCamera()) {\n <div class=\"kf-map-controls__group\">\n @if (hasRecenter()) {\n <div class=\"kf-map-controls__item\" kfTooltip=\"Centrar mapa\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"crosshair\"\n ariaLabel=\"Volver a encuadrar el mapa\"\n (click)=\"recenter.emit()\"\n />\n </div>\n }\n @if (hasFollow()) {\n <div class=\"kf-map-controls__item\" [kfTooltip]=\"followLabel()\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"navigation\"\n [ariaLabel]=\"followActive() ? 'Dejar de seguir' : 'Seguir posici\u00F3n'\"\n [ariaPressed]=\"followActive()\"\n [disabled]=\"!canFollow()\"\n (click)=\"followToggle.emit()\"\n />\n </div>\n }\n </div>\n}\n@if (hasCluster()) {\n <div class=\"kf-map-controls__group\">\n <div class=\"kf-map-controls__item\" [kfTooltip]=\"clusterLabel()\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"grid\"\n [ariaLabel]=\"clusterActive() ? 'Desagrupar marcadores' : 'Agrupar marcadores cercanos'\"\n [ariaPressed]=\"clusterActive()\"\n (click)=\"clusterToggle.emit()\"\n />\n </div>\n </div>\n}\n@if (hasChrome()) {\n <div class=\"kf-map-controls__group\">\n @if (hasMapStyle()) {\n <kf-dropdown\n class=\"kf-map-controls__item\"\n [kfTooltip]=\"mapStyleLabel()\"\n kfTooltipPosition=\"left\"\n [(open)]=\"mapStyleOpen\"\n >\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n icon=\"layers\"\n [ariaLabel]=\"'Cambiar estilo de mapa (actual: ' + styleLabel() + ')'\"\n [ariaExpanded]=\"mapStyleOpen()\"\n (click)=\"mapStyleOpen.set(!mapStyleOpen())\"\n />\n <kf-dropdown-panel>\n <kf-map-theme-picker [active]=\"activeTheme()\" (themeSelect)=\"onThemeSelect($event)\" />\n </kf-dropdown-panel>\n </kf-dropdown>\n }\n @if (hasFullscreen()) {\n <div class=\"kf-map-controls__item\" [kfTooltip]=\"fullscreenLabel()\" kfTooltipPosition=\"left\">\n <kf-button\n variant=\"surface\"\n shape=\"circle\"\n size=\"sm\"\n [icon]=\"isFullscreen() ? 'minimize' : 'maximize'\"\n [ariaLabel]=\"isFullscreen() ? 'Salir de pantalla completa' : 'Pantalla completa'\"\n [ariaPressed]=\"isFullscreen()\"\n (click)=\"fullscreenToggle.emit()\"\n />\n </div>\n }\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;align-items:flex-end;gap:var(--kf-space-sm)}.kf-map-controls__item{position:relative;display:flex}.kf-map-controls__group{display:flex;flex-direction:column;gap:2px;background:var(--kf-color-surface);border-radius:var(--kf-radius-md);box-shadow:var(--kf-shadow-md);padding:3px}.kf-map-controls__group kf-button{--kf-color-surface: transparent;--kf-color-border: transparent;--kf-shadow-md: none}.kf-map-controls__group--zoom{gap:0}.kf-map-controls__group--zoom .kf-map-controls__item+.kf-map-controls__item{border-top:1px solid var(--kf-color-border)}\n"] }]
229
+ }], propDecorators: { controls: [{ type: i0.Input, args: [{ isSignal: true, alias: "controls", required: false }] }], clusterActive: [{ type: i0.Input, args: [{ isSignal: true, alias: "clusterActive", required: false }] }], followActive: [{ type: i0.Input, args: [{ isSignal: true, alias: "followActive", required: false }] }], canFollow: [{ type: i0.Input, args: [{ isSignal: true, alias: "canFollow", required: false }] }], isFullscreen: [{ type: i0.Input, args: [{ isSignal: true, alias: "isFullscreen", required: false }] }], styleLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "styleLabel", required: false }] }], activeTheme: [{ type: i0.Input, args: [{ isSignal: true, alias: "activeTheme", required: false }] }], zoomIn: [{ type: i0.Output, args: ["zoomIn"] }], zoomOut: [{ type: i0.Output, args: ["zoomOut"] }], recenter: [{ type: i0.Output, args: ["recenter"] }], followToggle: [{ type: i0.Output, args: ["followToggle"] }], clusterToggle: [{ type: i0.Output, args: ["clusterToggle"] }], themeSelect: [{ type: i0.Output, args: ["themeSelect"] }], fullscreenToggle: [{ type: i0.Output, args: ["fullscreenToggle"] }] } });
230
+
231
+ /// <reference types="geojson" />
232
+ // `tsconfig.lib.json` fija `types: []` a propósito (no filtrar globals
233
+ // ambientales a la librería publicada) — esta referencia explícita es la
234
+ // única forma de resolver el namespace `GeoJSON` que usan los tipos de
235
+ // `mapbox-gl` (`addSource({ type: 'geojson', data: ... })`) sin relajar esa
236
+ // restricción para el resto del archivo.
237
+ const DEFAULT_CENTER = { lat: 0, lng: 0 };
238
+ const DEFAULT_ZOOM = 2;
239
+ const FIT_BOUNDS_PADDING_PX = 48;
240
+ const FIT_BOUNDS_MAX_ZOOM = 16;
241
+ const ROUTE_SOURCE_ID = 'kf-map-route';
242
+ const ROUTE_LAYER_ID = 'kf-map-route-line';
243
+ /**
244
+ * Duraciones de cámara — todas pasan por `cameraDuration()`, que las
245
+ * colapsa a 0 si el usuario pidió `prefers-reduced-motion: reduce` (ver
246
+ * ese método). `INITIAL_FIT` es más larga que `RECENTER`: la entrada
247
+ * inicial es la primera impresión del mapa (vale la pena un momento
248
+ * "cinematográfico"); un recentrado manual es una corrección puntual que
249
+ * el usuario ya pidió a propósito, no hace falta hacerlo esperar de más.
250
+ */
251
+ const CAMERA_DURATION_MS = {
252
+ initialFit: 900,
253
+ recenter: 700,
254
+ zoomStep: 250,
255
+ };
256
+ /**
257
+ * Umbral de "caja degenerada" para `fitBounds()`, en grados — no en metros,
258
+ * para no necesitar trigonometría (la longitud de un grado de longitud
259
+ * varía con la latitud; la de un grado de latitud no). 0.0005° equivale a
260
+ * ~55 m en el ecuador de lado a lado, más chico en longitud a medida que la
261
+ * latitud crece. Se eligió para cubrir el caso real que rompía el encuadre
262
+ * (un solo marker, o vehículo+destino en el mismo sitio con dos fixes de
263
+ * GPS ligeramente distintos) sin tragarse una ruta corta real: cualquier
264
+ * separación de más de ~50 m entre los puntos sigue entrando a `fitBounds`
265
+ * normal.
266
+ */
267
+ const DEGENERATE_BOUNDS_THRESHOLD_DEG = 0.0005;
268
+ /** Zoom que reemplaza a `fitBounds()` en el caso degenerado — suficiente
269
+ * para ver contexto real de calle alrededor de un único punto. */
270
+ const SINGLE_POINT_ZOOM = 15;
271
+ const MARKER_CLUSTER_SOURCE_ID = 'kf-map-markers-cluster';
272
+ const CLUSTER_CIRCLE_LAYER_ID = 'kf-map-cluster-circle';
273
+ const CLUSTER_COUNT_LAYER_ID = 'kf-map-cluster-count';
274
+ /**
275
+ * Radio (px) del marker DOM de cluster (ver `addClusterMarkerToMap()`) —
276
+ * MISMOS umbrales que el `'circle-radius'` de `CLUSTER_CIRCLE_LAYER_ID`
277
+ * (`step(point_count, 14, 10, 18, 50, 22)`): el marker DOM tiene que cubrir
278
+ * exactamente el mismo área que la capa WebGL que pinta debajo, para que el
279
+ * hit target de mouse/teclado calce con lo que el usuario ve.
280
+ */
281
+ function clusterMarkerRadiusPx(pointCount) {
282
+ if (pointCount >= 50) {
283
+ return 22;
284
+ }
285
+ if (pointCount >= 10) {
286
+ return 18;
287
+ }
288
+ return 14;
289
+ }
290
+ /** Margen mínimo (px) contra el borde del contenedor del mapa antes de
291
+ * recortar/voltear un popup (marker o cluster) — ver `positionFloatingPopup()`. */
292
+ const POPUP_EDGE_MARGIN_PX = 8;
293
+ /** Separación (px) entre el punto de pantalla de un cluster y su popup
294
+ * resumen — equivalente al `calc(100% + 12px)` que ya usa el popup de
295
+ * marker individual vía CSS, pero acá en JS porque el popup de cluster no
296
+ * es hijo de ningún elemento posicionado por CSS (ver `renderClusterPopup()`). */
297
+ const CLUSTER_POPUP_GAP_PX = 16;
298
+ /** Duración de la corrección de cámara en modo `follow` — bastante corta
299
+ * para sentirse "pegada" al marker, sin el salto seco de `jumpTo`. */
300
+ const FOLLOW_EASE_DURATION_MS = 400;
301
+ /** Ver el comentario junto a `markReady` en `initializeMap()` — respaldo
302
+ * para no depender exclusivamente de `'load'`, que en al menos una cadena
303
+ * real verificada nunca llega a disparar pese a que el mapa carga bien. */
304
+ const MAP_READY_FALLBACK_TIMEOUT_MS = 4000;
305
+ /**
306
+ * Mapa con motor real de Mapbox GL JS — decisión de producto ya cerrada
307
+ * (comparación de 6 proveedores). El access token vive a nivel app, no por
308
+ * uso: ver `provideMapConfig` en `map-config.ts` (mismo criterio que
309
+ * `API_CONFIG` de `packages/api-client`), inyectado una vez en
310
+ * `app.config.ts` de cada app consumidora.
311
+ *
312
+ * `size` (`sm|md|lg` alturas fijas, `full` llena el contenedor padre) NO
313
+ * hace step-down por viewport como `kf-page-header`: ese mecanismo existe
314
+ * ahí porque un tamaño de FUENTE fijo se corta en pantallas chicas; una
315
+ * caja de mapa no tiene ese problema — el canvas de Mapbox ya es
316
+ * responsive dentro de cualquier tamaño de contenedor. Lo que sí hace
317
+ * falta (y que el header no necesita) es un `ResizeObserver` real: Mapbox
318
+ * no se entera solo de que su contenedor cambió de tamaño (un layout que
319
+ * colapsa un sidebar, un cambio de orientación) — hay que llamar
320
+ * `map.resize()` a mano cuando el contenedor cambia (mismo motivo por el
321
+ * que `fullscreenchange` también llama `resize()`, ver `onFullscreenChange`).
322
+ *
323
+ * `variant` es un eje ortogonal a `size` (mismo criterio que `variant` +
324
+ * `tone` en `kf-badge`): `interactive` trae la posibilidad de controles
325
+ * flotantes reales (ver `controls`); `preview` (`interactive: false` de
326
+ * Mapbox GL) es para una card de lista, sin chrome y sin foco por teclado —
327
+ * no hace nada al recibir foco, así que no debe formar parte del tab order,
328
+ * y por eso NUNCA renderiza `controls` aunque el consumidor los pida:
329
+ * togglear "seguir" o abrir pantalla completa no tiene sentido en un mapa
330
+ * que no acepta interacción. Ninguna de las dos variantes oculta la
331
+ * atribución: es requisito de licencia de Mapbox, no cosmético.
332
+ *
333
+ * `theme` (`light|dark|streets|satellite|outdoors`) resuelve el estilo real
334
+ * de Mapbox — eje totalmente distinto del `[data-theme]` de
335
+ * `tokens/themes/*` (paleta de marca de la librería): ninguno de los 5
336
+ * temas de `tokens/` es oscuro todavía, así que no hay ninguna señal real
337
+ * de la que derivar un default "según el tema de marca activo" — construir
338
+ * esa detección ahora sería simular una señal que no existe.
339
+ *
340
+ * `controls` (`MapControlKind[]`, default `[]`) es siempre opt-in: un mapa
341
+ * sin `controls` no dibuja ningún botón flotante, ni siquiera en
342
+ * `interactive` (antes de esta versión, `interactive` agregaba el
343
+ * `NavigationControl` default de Mapbox gratis — se sacó a propósito: era
344
+ * el look genérico de Mapbox, no el de esta librería, exactamente la queja
345
+ * que motivó este rediseño. Un consumidor que quería esos botones de zoom
346
+ * ahora los pide con `[controls]="['zoom']"`). Cada kind es una pieza
347
+ * independiente entre sí (ver `MapControls.ts` para el detalle de cada
348
+ * una) — agregar una no reacomoda ni depende de las demás.
349
+ *
350
+ * <kf-map label="Ubicación del vehículo y del destino" [markers]="markers()" />
351
+ * <kf-map size="sm" variant="preview" label="Vista previa de la ruta" [route]="route()" />
352
+ * <kf-map label="Flota completa" [markers]="fleet()" [controls]="['zoom', 'recenter', 'cluster', 'follow']" />
353
+ */
354
+ class MapView {
355
+ size = input('md', /* @ts-ignore */
356
+ ...(ngDevMode ? [{ debugName: "size" }] : /* istanbul ignore next */ []));
357
+ variant = input('interactive', /* @ts-ignore */
358
+ ...(ngDevMode ? [{ debugName: "variant" }] : /* istanbul ignore next */ []));
359
+ markers = input([], /* @ts-ignore */
360
+ ...(ngDevMode ? [{ debugName: "markers" }] : /* istanbul ignore next */ []));
361
+ route = input(/* @ts-ignore */
362
+ ...(ngDevMode ? [undefined, { debugName: "route" }] : /* istanbul ignore next */ []));
363
+ fitToMarkers = input(true, { ...(ngDevMode ? { debugName: "fitToMarkers" } : /* istanbul ignore next */ {}), transform: booleanAttribute });
364
+ center = input(/* @ts-ignore */
365
+ ...(ngDevMode ? [undefined, { debugName: "center" }] : /* istanbul ignore next */ []));
366
+ zoom = input(/* @ts-ignore */
367
+ ...(ngDevMode ? [undefined, { debugName: "zoom" }] : /* istanbul ignore next */ []));
368
+ /**
369
+ * Default `'streets'`, no `'light'` ni `'dark'`. `'dark'` se probó
370
+ * primero por coherencia con la paleta navy de marca, pero una
371
+ * verificación visual real sobre una ubicación de servicio típica (una
372
+ * faena/zona rural, no un centro urbano denso) mostró el problema real:
373
+ * `dark-v11` casi no tiene datos que pintar fuera de ciudades grandes —
374
+ * el mapa se ve prácticamente todo negro, ilegible, exactamente donde
375
+ * más importa que se lea bien (ubicaciones de terreno, no de oficina).
376
+ * `'streets'` SIEMPRE tiene cartografía base visible (calles, nombres,
377
+ * uso de suelo) sin importar la densidad del lugar — es el único de los
378
+ * 5 estilos sin ese riesgo. Sigue siendo overrideable por el consumidor. */
379
+ theme = input('streets', /* @ts-ignore */
380
+ ...(ngDevMode ? [{ debugName: "theme" }] : /* istanbul ignore next */ []));
381
+ /** Controles flotantes a mostrar — ver JSDoc de la clase y `MapControls.ts`. */
382
+ controls = input([], /* @ts-ignore */
383
+ ...(ngDevMode ? [{ debugName: "controls" }] : /* istanbul ignore next */ []));
384
+ /**
385
+ * Descripción accesible real del mapa — el `<div>` donde Mapbox renderiza
386
+ * su canvas queda vacío de semántica para un lector de pantalla. Requerido
387
+ * a propósito: un default genérico ("Mapa") no dice qué está mostrando
388
+ * ESTE mapa; cada consumidor conoce su propio caso real (ej. "Ubicación
389
+ * del vehículo y del destino").
390
+ */
391
+ label = input.required(/* @ts-ignore */
392
+ ...(ngDevMode ? [{ debugName: "label" }] : /* istanbul ignore next */ []));
393
+ /** Se emite al hacer click sobre un marker que declaró `id` (ver JSDoc de
394
+ * `MapMarker.id`) — un marker SIN `id` no es clickeable, no emite nada.
395
+ * No dispara en modo `cluster` (los markers pasan a ser capas WebGL sin
396
+ * DOM propio donde colgar un listener) ni en `variant="preview"` (el
397
+ * mapa entero no es interactivo ahí). */
398
+ markerSelect = output();
399
+ /** Se emite al clickear un botón de `MapMarker.popup.actions` — ver el
400
+ * JSDoc de `popup` para por qué reporta `actionKey` (nunca el `label`) y
401
+ * por qué el click no dispara además `markerSelect`. */
402
+ markerAction = output();
403
+ mapConfig = inject(MAP_CONFIG);
404
+ environmentInjector = inject(EnvironmentInjector);
405
+ document = inject(DOCUMENT);
406
+ destroyRef = inject(DestroyRef);
407
+ hostElementRef = inject((ElementRef));
408
+ containerRef = viewChild.required('mapContainer', /* @ts-ignore */
409
+ ...(ngDevMode ? [{ debugName: "containerRef" }] : /* istanbul ignore next */ []));
410
+ /** `preview` no es foco-navegable ni se anuncia como región interactiva. */
411
+ isPreview = computed(() => this.variant() === 'preview', /* @ts-ignore */
412
+ ...(ngDevMode ? [{ debugName: "isPreview" }] : /* istanbul ignore next */ []));
413
+ containerRole = computed(() => (this.isPreview() ? 'img' : 'region'), /* @ts-ignore */
414
+ ...(ngDevMode ? [{ debugName: "containerRole" }] : /* istanbul ignore next */ []));
415
+ /** Ver estado de carga/error real, más abajo (`status`). */
416
+ status = signal('loading', /* @ts-ignore */
417
+ ...(ngDevMode ? [{ debugName: "status" }] : /* istanbul ignore next */ []));
418
+ /** Elección manual del consumidor final vía el control `mapStyle` — pisa
419
+ * `theme()` de forma sostenida (no vuelve a `theme()` sola) hasta que se
420
+ * destruya este `kf-map`; ver `resolvedTheme()`. */
421
+ userTheme = signal(null, /* @ts-ignore */
422
+ ...(ngDevMode ? [{ debugName: "userTheme" }] : /* istanbul ignore next */ []));
423
+ styleLabel = computed(() => THEME_LABEL[this.resolvedTheme()], /* @ts-ignore */
424
+ ...(ngDevMode ? [{ debugName: "styleLabel" }] : /* istanbul ignore next */ []));
425
+ /** Igual que `resolvedTheme()` pero público para el template — lo necesita
426
+ * `MapThemePicker` (vía `MapControls`) para marcar la opción activa. */
427
+ activeTheme = computed(() => this.resolvedTheme(), /* @ts-ignore */
428
+ ...(ngDevMode ? [{ debugName: "activeTheme" }] : /* istanbul ignore next */ []));
429
+ clusterActive = signal(false, /* @ts-ignore */
430
+ ...(ngDevMode ? [{ debugName: "clusterActive" }] : /* istanbul ignore next */ []));
431
+ followActive = signal(false, /* @ts-ignore */
432
+ ...(ngDevMode ? [{ debugName: "followActive" }] : /* istanbul ignore next */ []));
433
+ hasLiveMarker = computed(() => this.markers().some((marker) => marker.live), /* @ts-ignore */
434
+ ...(ngDevMode ? [{ debugName: "hasLiveMarker" }] : /* istanbul ignore next */ []));
435
+ isFullscreen = signal(false, /* @ts-ignore */
436
+ ...(ngDevMode ? [{ debugName: "isFullscreen" }] : /* istanbul ignore next */ []));
437
+ /** Sin estilo de marca propio (Mapbox Studio) no hay nada que ciclar con
438
+ * `mapStyle` — se filtra en vez de dejar un botón que no cambiaría nada. */
439
+ effectiveControls = computed(() => {
440
+ const requested = this.controls();
441
+ return this.mapConfig.styleUrl ? requested.filter((kind) => kind !== 'mapStyle') : requested;
442
+ }, /* @ts-ignore */
443
+ ...(ngDevMode ? [{ debugName: "effectiveControls" }] : /* istanbul ignore next */ []));
444
+ showControls = computed(() => this.variant() === 'interactive' && this.effectiveControls().length > 0, /* @ts-ignore */
445
+ ...(ngDevMode ? [{ debugName: "showControls" }] : /* istanbul ignore next */ []));
446
+ map = null;
447
+ /** `extraIconRefs`: íconos de las acciones del popup (ver `buildMarkerPopup()`)
448
+ * — se crean con `createComponent()` igual que `iconRef` (el pin), así que
449
+ * necesitan el mismo `.destroy()` en `clearMarkers()`. */
450
+ markerHandles = [];
451
+ /** Markers DOM reales sobre cada cluster real (punto 1, a11y) — separados
452
+ * de `markerHandles` porque se reconstruyen en su propio ciclo (ver
453
+ * `syncClusterMarkers()`) y no tienen `iconRef` propio (sin `kf-icon`
454
+ * adentro, ver JSDoc de `addClusterMarkerToMap()`). */
455
+ clusterMarkerHandles = [];
456
+ resizeObserver = null;
457
+ currentStyle = '';
458
+ mapReady = false;
459
+ /** Popup resumen de un cluster real (ver `renderClusterPopup()`) — a
460
+ * diferencia del popup de marker individual (siempre en el DOM, mostrado
461
+ * por CSS `:hover`/`:focus-within`), este se crea/destruye a demanda: no
462
+ * cuelga de ningún elemento con estado hover propio, lo abre un click
463
+ * sobre una capa WebGL sin DOM. */
464
+ clusterPopupElement = null;
465
+ clusterPopupIconRefs = [];
466
+ /** Coordenadas del cluster abierto — necesarias para volver a proyectar
467
+ * contra pantalla en cada `'move'` (ver `repositionClusterPopup()`); el
468
+ * cluster en sí no se mueve, pero su posición EN PANTALLA sí cuando se
469
+ * hace pan. */
470
+ clusterPopupCoordinates = null;
471
+ onFullscreenChange = () => {
472
+ this.isFullscreen.set(this.document.fullscreenElement === this.hostElementRef.nativeElement);
473
+ // El propio contenedor cambia de tamaño al entrar/salir de pantalla
474
+ // completa — mismo motivo que el `ResizeObserver` de más abajo, pero
475
+ // ESTE cambio de tamaño no pasa por él (la Fullscreen API no dispara
476
+ // un resize de layout normal en todos los navegadores).
477
+ this.map?.resize();
478
+ };
479
+ constructor() {
480
+ afterNextRender(() => this.initializeMap());
481
+ this.document.addEventListener('fullscreenchange', this.onFullscreenChange);
482
+ // Reactivo a `markers`/`route`/`fitToMarkers`/`center`/`zoom`/`theme`/
483
+ // `controls`/`clusterActive`/`followActive` — el primer sync real ya lo
484
+ // hace `initializeMap()` (ver comentario ahí sobre por qué la ruta NO se
485
+ // sincroniza en esa primera pasada), así que este effect solo necesita
486
+ // reaccionar a cambios posteriores.
487
+ afterRenderEffect(() => {
488
+ const style = this.resolvedStyle();
489
+ if (!this.map) {
490
+ return;
491
+ }
492
+ if (this.mapReady && style !== this.currentStyle) {
493
+ this.currentStyle = style;
494
+ this.map.setStyle(style);
495
+ }
496
+ this.syncMarkers();
497
+ // `setStyle()` recién dispara `'style.load'` de forma asíncrona (ver
498
+ // el listener en `initializeMap`), que es quien repone la ruta (y el
499
+ // cluster, si está activo) en ese caso. Este llamado cubre el resto:
500
+ // un cambio de `route()`/`markers()` sin cambio de `theme()` no
501
+ // dispara ningún evento de Mapbox por sí solo — sin esto, la cámara
502
+ // se reencuadraría pero los datos dibujados quedarían viejos.
503
+ // `syncRoute()` ya revisa `isStyleLoaded()`, así que no hace nada si
504
+ // el estilo está a mitad de cargar.
505
+ this.syncRoute();
506
+ this.syncCamera();
507
+ });
508
+ this.destroyRef.onDestroy(() => {
509
+ this.document.removeEventListener('fullscreenchange', this.onFullscreenChange);
510
+ this.resizeObserver?.disconnect();
511
+ this.clearMarkers();
512
+ this.clearClusterMarkers();
513
+ this.closeClusterPopup();
514
+ this.map?.remove();
515
+ this.map = null;
516
+ });
517
+ }
518
+ // Ex-`assignWorkerClass()`: el bundle UMD de `mapbox-gl` (`from 'mapbox-gl'`
519
+ // sin sufijo) arma su Web Worker serializando el código del worker como
520
+ // string y cargándolo por Blob URL — un bundler ESM-nativo (esbuild, el
521
+ // builder `application` de Angular) minifica ESE string también, sin saber
522
+ // que corre verbatim en otro contexto, y el worker termina con un
523
+ // identificador roto en runtime (`ReferenceError: <letra> is not defined`,
524
+ // ver mapbox/mapbox-gl-js#13700 y angular/angular-cli#27378). El método que
525
+ // vivía acá reemplazaba ese mecanismo a mano, reasignando
526
+ // `mapboxgl.workerClass` a un `Worker` real armado con
527
+ // `new Worker(new URL('./mapbox-gl-csp.worker.js', import.meta.url), {type:
528
+ // 'module'})`.
529
+ //
530
+ // Al migrar a `mapbox-gl/esm` (ver el import de arriba) ese fix quedó
531
+ // obsoleto, no solo innecesario: `mapbox-gl/esm` NO expone `workerClass` en
532
+ // absoluto (no hay objeto `mapboxgl` de import default sobre el cual
533
+ // asignarlo — confirmado leyendo `dist/esm/core.js`, sin ese identificador
534
+ // en ningún export). Su propio mecanismo interno de carga de worker
535
+ // (`dist/esm/core.js`, función que arma el pool de workers) YA usa
536
+ // exactamente el mismo patrón literal `new Worker(new URL("worker.js",
537
+ // import.meta.url), {type: "module"})` que este método replicaba a mano
538
+ // para el bundle UMD — es decir, la ESM build de `mapbox-gl` ya resuelve
539
+ // este problema nativamente, con su propio `dist/esm/worker.js` al lado de
540
+ // `core.js`. No hace falta (ni es posible) reemplazar nada.
541
+ //
542
+ // Sin verificar con un browser real todavía (sin token de Mapbox ni página
543
+ // de `kf-map` en el playground de este repo) que ese worker nativo carga
544
+ // correctamente una vez re-bundleado por el build de un consumidor real
545
+ // (`route-assistance-monorepo`) — pendiente de QA manual antes de dar esto
546
+ // por cerrado en ese frente. `mapbox-gl-csp.worker.js` y su entrada en
547
+ // `ng-package.json` se retiraron junto con este método: nada los importa ya.
548
+ initializeMap() {
549
+ const container = this.containerRef().nativeElement;
550
+ const style = this.resolvedStyle();
551
+ this.map = new Map({
552
+ container,
553
+ accessToken: this.mapConfig.accessToken,
554
+ style,
555
+ interactive: this.variant() === 'interactive',
556
+ // Se agrega a mano más abajo, con `compact: false`: la atribución de
557
+ // Mapbox tiene que quedar SIEMPRE visible (requisito de licencia, no
558
+ // cosmético) — el default `compact` la colapsa detrás de un ícono en
559
+ // contenedores angostos, exactamente lo que no puede pasar acá.
560
+ attributionControl: false,
561
+ center: this.toLngLat(this.center() ?? DEFAULT_CENTER),
562
+ zoom: this.zoom() ?? DEFAULT_ZOOM,
563
+ // Mapbox GL JS v3 elige la proyección "globe" (esfera 3D con atmósfera)
564
+ // sola a partir de cierto zoom bajo, incluso con estilos clásicos como
565
+ // `streets-v12` — no hace falta el estilo "Standard" para que pase. El
566
+ // constructor arranca siempre en `DEFAULT_ZOOM` (2) ANTES de que
567
+ // `fitBounds()` corra (recién en `markReady()`), así que sin esto el
568
+ // primer frame real que ve el usuario es ese globo, con los markers
569
+ // amontonados cerca de un borde — el bug real reportado ("se ve como un
570
+ // globo terráqueo"). `'mercator'` fija la proyección clásica sin
571
+ // importar el zoom en ningún momento del ciclo de vida del mapa.
572
+ projection: 'mercator',
573
+ });
574
+ this.currentStyle = style;
575
+ this.mapReady = true;
576
+ this.map.addControl(new AttributionControl({ compact: false }));
577
+ // Ya no se agrega `NavigationControl` (ni ningún otro control default de
578
+ // Mapbox): ver JSDoc de `controls` en la clase — el chrome flotante de
579
+ // esta librería es siempre `kf-map-controls`, opt-in, nunca el look
580
+ // genérico de Mapbox.
581
+ // `load` marca el mapa como visualmente listo (primer frame con todas
582
+ // sus fuentes cargadas) — DISTINTO de `mapReady` de arriba, que solo
583
+ // significa "el objeto `Map` ya existe" y gatea `setStyle()`. Mientras
584
+ // no llega, el overlay de `status()` cubre el canvas a medio pintar.
585
+ //
586
+ // `markReady` se cablea a `load` PERO también a un timeout de respaldo:
587
+ // en una verificación real (Chromium headless vía Playwright, mismo
588
+ // navegador que corre cualquier E2E de los consumidores) se comprobó
589
+ // que `render`/`data`/`sourcedata`/`styledata` disparan con normalidad
590
+ // — las tiles llegan y el mapa pinta de verdad — pero `load` (y `idle`)
591
+ // no llegan a disparar nunca en esa cadena concreta. No se pudo aislar
592
+ // la causa raíz con certeza en su momento (sospecha original: alguna
593
+ // interacción puntual con el `workerClass` custom que este componente
594
+ // asignaba a mano sobre el bundle UMD de `mapbox-gl`, ver el comentario
595
+ // sobre la migración a `mapbox-gl/esm` más arriba en `initializeMap()`).
596
+ // Esa asignación custom ya no existe — pero la sospecha nunca se confirmó
597
+ // con certeza, así que el fallback se mantiene sin cambios: no hay
598
+ // evidencia todavía (falta QA manual en un browser real) de que la migración
599
+ // haya resuelto esto, y depender SOLO de `load` deja al usuario con un
600
+ // spinner infinito sobre un mapa que en los hechos ya funciona — un
601
+ // fallback acotado es la salida segura sin importar cuál sea la causa real.
602
+ const markReady = () => {
603
+ if (this.status() === 'loading') {
604
+ this.status.set('ready');
605
+ // `fitBounds()` (dentro de `syncCamera()`) calcula el zoom de
606
+ // encuadre en base al tamaño ACTUAL del canvas de Mapbox — pero ese
607
+ // canvas se creó con el contenedor todavía detrás del overlay de
608
+ // carga (`display` normal, sin `hidden`, pero en layouts flex/grid
609
+ // que recién terminan de asentarse el tamaño real puede no estar
610
+ // listo todavía en este punto del ciclo). Un tamaño transitorio le
611
+ // da a Mapbox un ancho/alto de referencia equivocado y el zoom
612
+ // resultante queda mal. `resize()` SÍNCRONO justo antes de encuadrar
613
+ // fuerza a Mapbox a releer el tamaño real del contenedor primero —
614
+ // mismo mecanismo que ya usa el `ResizeObserver` de más abajo para
615
+ // cambios posteriores, aplicado acá una vez, a mano, en el momento
616
+ // exacto que más importa: el primer encuadre real.
617
+ this.map?.resize();
618
+ // Recién ACÁ el canvas queda visible detrás del overlay — es el
619
+ // único momento en que animar la cámara tiene sentido. Antes este
620
+ // fit corría en `initializeMap()`, sincrónico, ANTES de que el
621
+ // mapa se pintara — la animación entera pasaba oculta detrás del
622
+ // spinner y lo único que el usuario veía era el resultado final de
623
+ // golpe (el bug real: "no veo el efecto, aparece centrado de
624
+ // golpe"). Ver `CAMERA_DURATION_MS.initialFit`.
625
+ this.syncCamera(CAMERA_DURATION_MS.initialFit);
626
+ }
627
+ };
628
+ this.map.on('load', markReady);
629
+ const readyFallbackTimer = setTimeout(markReady, MAP_READY_FALLBACK_TIMEOUT_MS);
630
+ this.destroyRef.onDestroy(() => clearTimeout(readyFallbackTimer));
631
+ // Cualquier error ANTES de llegar a `ready` es grave (token inválido,
632
+ // sin red, estilo inexistente): no hay mapa usable que mostrar debajo
633
+ // del overlay. Un error DESPUÉS de `ready` (ej. una tile puntual que no
634
+ // cargó) no debería tapar retroactivamente un mapa que en general
635
+ // funciona — por eso se ignora ahí, en vez de andar adivinando el
636
+ // mensaje exacto de cada causa posible.
637
+ this.map.on('error', () => {
638
+ if (this.status() !== 'ready') {
639
+ this.status.set('error');
640
+ }
641
+ });
642
+ // `addSource`/`addLayer` fallan si el estilo todavía no cargó — por eso
643
+ // la ruta (y el cluster, si está activo) no se sincronizan en la pasada
644
+ // inicial de más abajo, solo acá. `.on` (no `.once`): cada `setStyle()`
645
+ // posterior (cambio de `theme`) vuelve a disparar este evento y hay que
646
+ // reponerlos, porque `setStyle` descarta cualquier source/layer custom
647
+ // del estilo anterior. Los markers en DOM (`syncMarkers()` sin
648
+ // clustering) no dependen del estilo y por eso no se repiten acá.
649
+ this.map.on('style.load', () => {
650
+ this.syncRoute();
651
+ if (this.clusterActive()) {
652
+ this.syncClusterSource();
653
+ }
654
+ });
655
+ // El click/foco sobre un cluster real NO se cablea contra la capa WebGL
656
+ // (`CLUSTER_CIRCLE_LAYER_ID` es puro pintado, sin nodo DOM — inalcanzable
657
+ // por teclado, WCAG 2.1.1). La interacción real vive en el marker DOM que
658
+ // `syncClusterMarkers()` crea por cada cluster vía `addClusterMarkerToMap()`
659
+ // (mismo patrón que `addMarkerToMap()`: `role="button"`, `tabindex="0"`,
660
+ // `click`+`keydown` de Enter/Space), así que acá no hace falta ningún
661
+ // listener de `click`/`mouseenter`/`mouseleave` sobre la capa.
662
+ // Qué puntos quedan "sueltos" (sin agrupar) depende del zoom actual —
663
+ // Mapbox reclusteriza sola en cada cambio, esto solo reacciona.
664
+ // `'data'` cubre la primera vez que la fuente termina de cargar (antes
665
+ // de eso `querySourceFeatures()` no tiene nada que devolver);
666
+ // `'moveend'` cubre cualquier pan/zoom posterior. No se usa `'render'`
667
+ // (dispara ~60/s) ni `'move'` continuo para esto: reconstruir markers
668
+ // en cada frame de una animación de cámara sería puro parpadeo sin
669
+ // ganar nada, `'moveend'` (una vez que la interacción termina) alcanza.
670
+ this.map.on('data', (event) => {
671
+ // `'data'` cubre tanto datos de `source` como de `style` — solo el
672
+ // primero trae `sourceId`/`isSourceLoaded`, de ahí el chequeo de
673
+ // `dataType` antes de leerlos (si no, TypeScript no puede angostar la
674
+ // unión `MapSourceDataEvent | MapStyleDataEvent`).
675
+ if (event.dataType === 'source' &&
676
+ this.clusterActive() &&
677
+ event.sourceId === MARKER_CLUSTER_SOURCE_ID &&
678
+ event.isSourceLoaded) {
679
+ this.syncClusterMarkers();
680
+ }
681
+ });
682
+ this.map.on('moveend', () => {
683
+ if (this.clusterActive()) {
684
+ this.syncClusterMarkers();
685
+ }
686
+ });
687
+ // El popup resumen de cluster (ver `renderClusterPopup()`) no es hijo de
688
+ // ningún elemento que Mapbox reposicione solo — sigue al punto de
689
+ // pantalla a mano en cada pan (`'move'`, sí continuo acá: reproyectar
690
+ // 2 números es barato, nada que ver con reconstruir markers arriba). Se
691
+ // cierra apenas empieza un zoom: el `cluster_id` que abrió el popup deja
692
+ // de tener sentido en cuanto la agrupación cambia, y esperar a que
693
+ // termine el zoom para recién ahí darse cuenta sería mostrar datos ya
694
+ // viejos un rato.
695
+ this.map.on('move', () => this.repositionClusterPopup());
696
+ this.map.on('zoomstart', () => this.closeClusterPopup());
697
+ this.resizeObserver = new ResizeObserver(() => this.map?.resize());
698
+ this.resizeObserver.observe(container);
699
+ this.syncMarkers();
700
+ // El fit animado inicial NO corre acá — ver `markReady()` más arriba:
701
+ // recién cuando el overlay de carga se saca tiene sentido animar algo
702
+ // que el usuario pueda ver. Mientras carga, la cámara se queda donde el
703
+ // constructor de `MapboxMap` la dejó (arriba) — invisible de todos
704
+ // modos detrás del overlay.
705
+ }
706
+ resolvedTheme() {
707
+ return this.userTheme() ?? this.theme();
708
+ }
709
+ resolvedStyle() {
710
+ // Un estilo de marca custom (Mapbox Studio) no tiene un tema que elegir
711
+ // — si el config de la app lo define, gana siempre sobre `theme()`/`userTheme()`.
712
+ return this.mapConfig.styleUrl ?? STYLE_BY_THEME[this.resolvedTheme()];
713
+ }
714
+ toLngLat(point) {
715
+ return [point.lng, point.lat];
716
+ }
717
+ /**
718
+ * `matchMedia` (no una media query CSS): esta preferencia decide la
719
+ * `duration` que se le pasa a la API de cámara de Mapbox, que corre en
720
+ * JS/WebGL — CSS no tiene nada que animar acá. `document.defaultView` (no
721
+ * el `window` global) para que quede mockeable en test igual que el resto
722
+ * de la clase usa `DOCUMENT` inyectado.
723
+ */
724
+ prefersReducedMotion() {
725
+ return (this.document.defaultView?.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false);
726
+ }
727
+ /** Colapsa cualquier duración a 0 (instantáneo, sin animar) si el usuario
728
+ * pidió `prefers-reduced-motion: reduce` — un único punto de verdad para
729
+ * que ningún movimiento de cámara se salga de esa preferencia. */
730
+ cameraDuration(baseMs) {
731
+ return this.prefersReducedMotion() ? 0 : baseMs;
732
+ }
733
+ syncCamera(duration = CAMERA_DURATION_MS.recenter) {
734
+ if (!this.map) {
735
+ return;
736
+ }
737
+ const followTarget = this.followActive()
738
+ ? this.markers().find((marker) => marker.live)
739
+ : undefined;
740
+ if (followTarget) {
741
+ // `easeTo`, no `jumpTo`: en modo "seguir" la cámara se corrige de a
742
+ // poco detrás del marker en movimiento, no salta en seco cada vez que
743
+ // llega una posición nueva.
744
+ this.map.easeTo({
745
+ center: this.toLngLat(followTarget.position),
746
+ duration: this.cameraDuration(FOLLOW_EASE_DURATION_MS),
747
+ });
748
+ return;
749
+ }
750
+ if (this.fitToMarkers()) {
751
+ const bounds = this.computeBounds();
752
+ if (bounds) {
753
+ this.frameBounds(bounds, duration);
754
+ return;
755
+ }
756
+ }
757
+ // `easeTo` con la duración calculada — no `jumpTo`: con
758
+ // `prefers-reduced-motion` la duración cae a 0 y el efecto es idéntico a
759
+ // un salto instantáneo, sin necesitar una rama de código aparte.
760
+ this.map.easeTo({
761
+ center: this.toLngLat(this.center() ?? DEFAULT_CENTER),
762
+ zoom: this.zoom() ?? DEFAULT_ZOOM,
763
+ duration: this.cameraDuration(duration),
764
+ });
765
+ }
766
+ /**
767
+ * Encuadra `bounds` con `fitBounds()`, salvo que sea una caja degenerada
768
+ * (ver `DEGENERATE_BOUNDS_THRESHOLD_DEG`) — un solo punto total (un único
769
+ * marker sin ruta, o vehículo+destino prácticamente en el mismo sitio) le
770
+ * da a `fitBounds()` un ancho y alto ~cero, que Mapbox resuelve con un
771
+ * `padding`/zoom que en la práctica deja el mapa mayormente en blanco
772
+ * ("se ve todo blanco", el bug real que esto corrige). Un `easeTo` con
773
+ * zoom fijo sensato muestra contexto real de calle en ese caso, animado
774
+ * igual que el resto (antes era un `jumpTo` seco).
775
+ */
776
+ frameBounds(bounds, duration = CAMERA_DURATION_MS.recenter) {
777
+ if (!this.map) {
778
+ return;
779
+ }
780
+ if (this.isDegenerateBounds(bounds)) {
781
+ this.map.easeTo({
782
+ center: bounds.getCenter(),
783
+ zoom: SINGLE_POINT_ZOOM,
784
+ duration: this.cameraDuration(duration),
785
+ });
786
+ return;
787
+ }
788
+ this.map.fitBounds(bounds, {
789
+ padding: FIT_BOUNDS_PADDING_PX,
790
+ maxZoom: FIT_BOUNDS_MAX_ZOOM,
791
+ duration: this.cameraDuration(duration),
792
+ });
793
+ }
794
+ isDegenerateBounds(bounds) {
795
+ const ne = bounds.getNorthEast();
796
+ const sw = bounds.getSouthWest();
797
+ return (Math.abs(ne.lat - sw.lat) < DEGENERATE_BOUNDS_THRESHOLD_DEG &&
798
+ Math.abs(ne.lng - sw.lng) < DEGENERATE_BOUNDS_THRESHOLD_DEG);
799
+ }
800
+ /**
801
+ * Extiende sobre markers Y route: el nombre del input sigue el caso de
802
+ * uso principal (encuadrar los markers), pero un mapa con solo `route` y
803
+ * sin `markers` tampoco debería quedar en el encuadre default inútil.
804
+ */
805
+ computeBounds() {
806
+ const points = [...this.markers().map((marker) => marker.position), ...(this.route() ?? [])];
807
+ if (points.length === 0) {
808
+ return null;
809
+ }
810
+ const bounds = new LngLatBounds();
811
+ for (const point of points) {
812
+ bounds.extend(this.toLngLat(point));
813
+ }
814
+ return bounds;
815
+ }
816
+ syncMarkers() {
817
+ if (!this.map) {
818
+ return;
819
+ }
820
+ if (this.clusterActive()) {
821
+ // El clustering nativo resuelve por fuente+capas GeoJSON los clusters
822
+ // REALES (2+ puntos agrupados) — pero un punto que Mapbox deja suelto
823
+ // (sin `point_count`) sigue siendo un marker DOM normal, con su
824
+ // `kf-icon`/tono/popup de siempre (ver JSDoc de `syncClusterMarkers()`
825
+ // sobre por qué). Por eso acá NO se limpian los markers incondicional-
826
+ // mente: `syncClusterMarkers()` decide cuáles siguen sueltos y
827
+ // reconstruye solo esos; este método solo se asegura de que la fuente
828
+ // de clusters exista/esté al día.
829
+ this.syncClusterSource();
830
+ this.syncClusterMarkers();
831
+ return;
832
+ }
833
+ this.clearClusterLayers();
834
+ this.clearClusterMarkers();
835
+ this.closeClusterPopup();
836
+ this.clearMarkers();
837
+ for (const markerData of this.markers()) {
838
+ this.addMarkerToMap(markerData);
839
+ }
840
+ }
841
+ /**
842
+ * Crea el marker de Mapbox (DOM + `kf-icon` + wiring de click/teclado/
843
+ * popup) para UN `MapMarker` y lo agrega a `markerHandles` — compartido
844
+ * entre el modo normal (`syncMarkers()`, todos los markers) y el modo
845
+ * cluster (`syncClusterMarkers()`, solo los que quedan sueltos): un punto
846
+ * suelto en modo cluster tiene que verse y comportarse EXACTAMENTE igual
847
+ * que en modo normal, así que no puede haber dos implementaciones.
848
+ */
849
+ addMarkerToMap(markerData) {
850
+ if (!this.map) {
851
+ return;
852
+ }
853
+ // `kf-icon` real (no un `<svg>` a mano): reusa el sprite compartido y
854
+ // se destruye igual que cualquier otro componente Angular al sacar el
855
+ // marker del mapa.
856
+ const iconRef = createComponent(Icon, { environmentInjector: this.environmentInjector });
857
+ iconRef.setInput('name', markerData.icon ?? 'map-pin');
858
+ iconRef.setInput('size', 'sm');
859
+ iconRef.changeDetectorRef.detectChanges();
860
+ const element = this.document.createElement('div');
861
+ element.className = [
862
+ 'kf-map__marker',
863
+ `kf-map__marker--${markerData.tone ?? 'neutral'}`,
864
+ markerData.live ? 'kf-map__marker--live' : '',
865
+ ]
866
+ .filter(Boolean)
867
+ .join(' ');
868
+ if (markerData.label) {
869
+ // Solo nombre accesible + tooltip nativo — `label` no abre un popup.
870
+ element.setAttribute('aria-label', markerData.label);
871
+ element.setAttribute('title', markerData.label);
872
+ }
873
+ const extraIconRefs = [];
874
+ if (markerData.id) {
875
+ // `id` (ver JSDoc de `MapMarker.id`) es lo único que hace a un
876
+ // marker clickeable — sin él, no hay con qué identificar de vuelta
877
+ // la selección en `markerSelect`.
878
+ const markerId = markerData.id;
879
+ element.classList.add('kf-map__marker--clickable');
880
+ element.setAttribute('role', 'button');
881
+ element.setAttribute('tabindex', '0');
882
+ element.addEventListener('click', () => this.markerSelect.emit(markerId));
883
+ element.addEventListener('keydown', (event) => {
884
+ if (event.key === 'Enter' || event.key === ' ') {
885
+ event.preventDefault();
886
+ this.markerSelect.emit(markerId);
887
+ }
888
+ });
889
+ // Solo con `id` el marker es clickeable/enfocable — sin eso, hover
890
+ // ni foco por teclado tendrían de qué revelar esta mini-card, así
891
+ // que `popup` sin `id` se ignora en silencio en vez de agregar un
892
+ // elemento que nunca se vería.
893
+ if (markerData.popup) {
894
+ const { element: popupElement, iconRefs: popupIconRefs } = this.buildMarkerPopup(markerData.popup, markerId, markerData.tone ?? 'neutral');
895
+ extraIconRefs.push(...popupIconRefs);
896
+ // Reposiciona (recorte de borde, ver `positionMarkerPopup()`) en
897
+ // el evento que dispara la aparición — el mecanismo de mostrar/
898
+ // ocultar sigue siendo 100% CSS (`:hover`/`:focus-within`), esto
899
+ // solo decide DÓNDE queda el popup antes de que se vuelva visible.
900
+ const reposition = () => this.positionMarkerPopup(element, popupElement);
901
+ element.addEventListener('mouseenter', reposition);
902
+ element.addEventListener('focusin', reposition);
903
+ element.appendChild(popupElement);
904
+ }
905
+ }
906
+ element.appendChild(iconRef.location.nativeElement);
907
+ const marker = new Marker({ element })
908
+ .setLngLat(this.toLngLat(markerData.position))
909
+ .addTo(this.map);
910
+ this.markerHandles.push({ marker, iconRef, extraIconRefs });
911
+ }
912
+ /**
913
+ * Mini-card hija del `<div>` del marker — mismo patrón que
914
+ * `.kf-map-controls__label` (ver `MapControls.css`): un elemento SIEMPRE
915
+ * presente en el DOM, oculto por defecto vía `opacity`/`pointer-events` en
916
+ * `MapView.css` y revelado por CSS puro en `:hover`/`:focus-within` del
917
+ * marker padre — sin JS de show/hide, y sin `mapboxgl.Popup` (evita una
918
+ * segunda gestión de overlay/posicionamiento cuando el CSS ya alcanza acá,
919
+ * igual que ya decidió `.kf-map-controls__label`).
920
+ *
921
+ * Todo el subárbol (título, descripción, `details`, `actions`) se arma con
922
+ * `document.createElement` puro, nunca `kf-text`/`kf-button`: este marker
923
+ * entero vive fuera del template de Angular (ver el comentario grande de
924
+ * `ViewEncapsulation.None` más arriba) — meter un componente real acá
925
+ * significaría un `createComponent()` propio por cada línea de texto, el
926
+ * mismo costo que ya paga `kf-icon` en `syncMarkers()` pero sin ninguna
927
+ * necesidad real (no hay nada dinámico que un componente resuelva mejor
928
+ * que texto plano estilado por clase). Es la MISMA decisión que ya regía
929
+ * para `title`/`description` antes de esta ronda — se extiende acá para no
930
+ * mezclar dos convenciones distintas dentro del mismo fragmento de DOM.
931
+ *
932
+ * `actions` sí necesita wiring propio (a diferencia del resto de la card,
933
+ * que burbujea su click al listener ya cableado sobre `element` en
934
+ * `syncMarkers()`): cada botón corta esa burbuja con `stopPropagation()`
935
+ * para reportar `markerAction` en vez de (además) `markerSelect`.
936
+ *
937
+ * Jerarquía visual de `actions` (ver JSDoc de `MapPopupAction`): la
938
+ * PRIMERA acción del array es la principal (`--primary`, fill); el resto
939
+ * es secundaria (`--ghost`, transparente) — mismo peso que ya usan
940
+ * `kf-button[variant=primary]`/`[variant=ghost]` en el resto de la
941
+ * librería, sin inventar un esquema de color nuevo acá.
942
+ *
943
+ * El punto de color junto al título (`kf-map__marker-popup-tone`) reusa
944
+ * el `tone` del propio marker — no el de cada `details` (que son pares
945
+ * label/value genéricos, sin un campo "cuál de estos es el estado"): es
946
+ * la misma señal que YA pinta el círculo del marker en el mapa, mostrada
947
+ * de nuevo acá para que el popup se lea sin tener que mirar el marker.
948
+ */
949
+ buildMarkerPopup(popup, markerId, tone) {
950
+ const iconRefs = [];
951
+ const card = this.document.createElement('div');
952
+ card.className = 'kf-map__marker-popup';
953
+ const heading = this.document.createElement('div');
954
+ heading.className = 'kf-map__marker-popup-heading';
955
+ const toneDot = this.document.createElement('span');
956
+ toneDot.className = `kf-map__marker-popup-tone kf-map__marker-popup-tone--${tone}`;
957
+ toneDot.setAttribute('aria-hidden', 'true');
958
+ const title = this.document.createElement('span');
959
+ title.className = 'kf-map__marker-popup-title';
960
+ title.textContent = popup.title;
961
+ heading.append(toneDot, title);
962
+ card.appendChild(heading);
963
+ if (popup.description) {
964
+ const description = this.document.createElement('span');
965
+ description.className = 'kf-map__marker-popup-description';
966
+ description.textContent = popup.description;
967
+ card.appendChild(description);
968
+ }
969
+ if (popup.details?.length) {
970
+ const list = this.document.createElement('dl');
971
+ list.className = 'kf-map__marker-popup-details';
972
+ for (const detail of popup.details) {
973
+ const row = this.document.createElement('div');
974
+ row.className = 'kf-map__marker-popup-detail';
975
+ const label = this.document.createElement('dt');
976
+ label.className = 'kf-map__marker-popup-detail-label';
977
+ label.textContent = detail.label;
978
+ const value = this.document.createElement('dd');
979
+ value.className = 'kf-map__marker-popup-detail-value';
980
+ value.textContent = detail.value;
981
+ row.append(label, value);
982
+ list.appendChild(row);
983
+ }
984
+ card.appendChild(list);
985
+ }
986
+ if (popup.actions?.length) {
987
+ const actions = this.document.createElement('div');
988
+ actions.className = 'kf-map__marker-popup-actions';
989
+ popup.actions.forEach((action, index) => {
990
+ const button = this.document.createElement('button');
991
+ button.type = 'button';
992
+ // Primera acción = principal (fill), resto = secundaria (ghost) —
993
+ // ver JSDoc de este método y de `MapPopupAction`.
994
+ button.className = `kf-map__marker-popup-action kf-map__marker-popup-action--${index === 0 ? 'primary' : 'ghost'}`;
995
+ // `title` nativo como respaldo: el label puede truncar con ellipsis
996
+ // si dos acciones no entran completas en el ancho mínimo del popup
997
+ // (ver `.kf-map__marker-popup-action-label`) — con esto, el texto
998
+ // completo sigue disponible al pasar el mouse por encima.
999
+ button.title = action.label;
1000
+ if (action.icon) {
1001
+ const iconRef = createComponent(Icon, { environmentInjector: this.environmentInjector });
1002
+ iconRef.setInput('name', action.icon);
1003
+ iconRef.setInput('size', 'sm');
1004
+ iconRef.changeDetectorRef.detectChanges();
1005
+ iconRefs.push(iconRef);
1006
+ button.appendChild(iconRef.location.nativeElement);
1007
+ }
1008
+ // Clase propia para poder truncar SOLO el texto (con ellipsis) sin
1009
+ // afectar al ícono — ver `.kf-map__marker-popup-action-label` en
1010
+ // MapView.css: con `flex:1` en el botón pero sin esto, dos acciones
1011
+ // de largo de texto distinto no se reparten el ancho parejo (el
1012
+ // `white-space:nowrap` fija un min-content propio que `flex:1` no
1013
+ // puede pisar sin `min-width:0` — mismo bug que en cualquier grid
1014
+ // con `1fr` a secas).
1015
+ const label = this.document.createElement('span');
1016
+ label.className = 'kf-map__marker-popup-action-label';
1017
+ label.textContent = action.label;
1018
+ button.appendChild(label);
1019
+ button.addEventListener('click', (domEvent) => {
1020
+ // Sin esto, el click también burbujearía hasta el listener de
1021
+ // `element` en `syncMarkers()` y dispararía `markerSelect` además
1022
+ // de `markerAction` — dos outputs por un solo click real.
1023
+ domEvent.stopPropagation();
1024
+ this.markerAction.emit({ markerId, actionKey: action.key });
1025
+ });
1026
+ actions.appendChild(button);
1027
+ });
1028
+ card.appendChild(actions);
1029
+ }
1030
+ return { element: card, iconRefs };
1031
+ }
1032
+ clearMarkers() {
1033
+ for (const { marker, iconRef, extraIconRefs } of this.markerHandles) {
1034
+ marker.remove();
1035
+ iconRef.destroy();
1036
+ for (const extraIconRef of extraIconRefs) {
1037
+ extraIconRef.destroy();
1038
+ }
1039
+ }
1040
+ this.markerHandles = [];
1041
+ }
1042
+ /**
1043
+ * Recorte de borde para el popup de marker individual (punto 2) —
1044
+ * `getBoundingClientRect()` del marker y del propio contenedor del mapa,
1045
+ * recalculado en el evento que dispara la apertura (`mouseenter`/
1046
+ * `focusin`, ver `addMarkerToMap()`). El mecanismo de mostrar/ocultar
1047
+ * sigue siendo 100% CSS; esto solo decide 3 clases modificadoras que
1048
+ * `MapView.css` traduce en alineación horizontal/vertical:
1049
+ *
1050
+ * `--align-start` — el popup se desborda por la izquierda del
1051
+ * contenedor si se centra sobre el marker: se alinea su borde
1052
+ * izquierdo con el del marker en vez de centrarlo.
1053
+ * `--align-end` — mismo caso, contra el borde derecho.
1054
+ * `--below` — no entra arriba del marker (cerca del borde superior del
1055
+ * contenedor): se muestra debajo en vez de arriba.
1056
+ *
1057
+ * El popup YA está en el DOM en este punto (oculto por `opacity`, no por
1058
+ * `display:none` — ver `MapView.css`), así que `getBoundingClientRect()`
1059
+ * devuelve su tamaño real aunque todavía no sea visible.
1060
+ */
1061
+ positionMarkerPopup(markerElement, popupElement) {
1062
+ const containerRect = this.containerRef().nativeElement.getBoundingClientRect();
1063
+ const markerRect = markerElement.getBoundingClientRect();
1064
+ const popupRect = popupElement.getBoundingClientRect();
1065
+ const markerCenterX = markerRect.left + markerRect.width / 2;
1066
+ const overflowsLeft = markerCenterX - popupRect.width / 2 < containerRect.left + POPUP_EDGE_MARGIN_PX;
1067
+ const overflowsRight = markerCenterX + popupRect.width / 2 > containerRect.right - POPUP_EDGE_MARGIN_PX;
1068
+ popupElement.classList.toggle('kf-map__marker-popup--align-start', overflowsLeft);
1069
+ popupElement.classList.toggle('kf-map__marker-popup--align-end', !overflowsLeft && overflowsRight);
1070
+ const overflowsTop = markerRect.top - popupRect.height - POPUP_EDGE_MARGIN_PX < containerRect.top;
1071
+ popupElement.classList.toggle('kf-map__marker-popup--below', overflowsTop);
1072
+ }
1073
+ /**
1074
+ * Clustering nativo de Mapbox (`cluster: true` en la fuente GeoJSON, no un
1075
+ * algoritmo armado a mano) — 2 capas, solo para clusters REALES (2+
1076
+ * puntos agrupados, `has: point_count`): círculo agregado y su label de
1077
+ * cantidad. Un punto que Mapbox deja suelto (sin `point_count`) YA NO
1078
+ * tiene capa WebGL propia — ver `syncClusterMarkers()`, que lo renderiza
1079
+ * como el mismo marker DOM de siempre (con ícono/tono/popup reales, cosa
1080
+ * que una capa `circle` no puede mostrar).
1081
+ */
1082
+ syncClusterSource() {
1083
+ if (!this.map || !this.map.isStyleLoaded()) {
1084
+ return;
1085
+ }
1086
+ const data = this.markersToFeatureCollection();
1087
+ const existing = this.map.getSource(MARKER_CLUSTER_SOURCE_ID);
1088
+ if (existing) {
1089
+ existing.setData(data);
1090
+ return;
1091
+ }
1092
+ this.map.addSource(MARKER_CLUSTER_SOURCE_ID, {
1093
+ type: 'geojson',
1094
+ data,
1095
+ cluster: true,
1096
+ clusterMaxZoom: 14,
1097
+ clusterRadius: 50,
1098
+ });
1099
+ this.map.addLayer({
1100
+ id: CLUSTER_CIRCLE_LAYER_ID,
1101
+ type: 'circle',
1102
+ source: MARKER_CLUSTER_SOURCE_ID,
1103
+ filter: ['has', 'point_count'],
1104
+ paint: {
1105
+ 'circle-color': this.resolveColorToken('--kf-color-gray-700'),
1106
+ 'circle-radius': ['step', ['get', 'point_count'], 14, 10, 18, 50, 22],
1107
+ 'circle-stroke-width': 2,
1108
+ 'circle-stroke-color': this.resolveColorToken('--kf-color-white'),
1109
+ },
1110
+ });
1111
+ this.map.addLayer({
1112
+ id: CLUSTER_COUNT_LAYER_ID,
1113
+ type: 'symbol',
1114
+ source: MARKER_CLUSTER_SOURCE_ID,
1115
+ filter: ['has', 'point_count'],
1116
+ layout: {
1117
+ 'text-field': ['get', 'point_count_abbreviated'],
1118
+ 'text-size': 12,
1119
+ },
1120
+ paint: {
1121
+ 'text-color': this.resolveColorToken('--kf-color-white'),
1122
+ },
1123
+ });
1124
+ }
1125
+ /**
1126
+ * Contraparte en DOM de la capa WebGL: mientras esa capa solo dibuja
1127
+ * clusters reales, ESTE método decide qué puntos quedan sueltos al zoom
1128
+ * ACTUAL (`querySourceFeatures` con el mismo filtro `!has(point_count)`
1129
+ * que ya usa Mapbox para separar cluster de leaf) y les arma el mismo
1130
+ * marker DOM que tendrían sin clustering — vía `addMarkerToMap()`, la
1131
+ * MISMA función que usa el modo normal, no una copia.
1132
+ *
1133
+ * `querySourceFeatures` puede devolver una feature repetida si cae cerca
1134
+ * de un borde interno de tile — de ahí el `Set` sobre `index` (la
1135
+ * property que identifica al `MapMarker` de origen, ver
1136
+ * `markersToFeatureCollection()`), no una lista con posibles duplicados.
1137
+ * Mismo motivo (y mismo `Set`, esta vez sobre `cluster_id`) para los
1138
+ * clusters reales que se resuelven acá abajo.
1139
+ *
1140
+ * Se llama solo cuando `clusterActive()` — ver los listeners `'data'`/
1141
+ * `'moveend'` en `initializeMap()` sobre CUÁNDO se llama esto.
1142
+ */
1143
+ syncClusterMarkers() {
1144
+ if (!this.map) {
1145
+ return;
1146
+ }
1147
+ const source = this.map.getSource(MARKER_CLUSTER_SOURCE_ID);
1148
+ if (!source) {
1149
+ return;
1150
+ }
1151
+ const singletonFeatures = this.map.querySourceFeatures(MARKER_CLUSTER_SOURCE_ID, {
1152
+ filter: ['!', ['has', 'point_count']],
1153
+ });
1154
+ const singletonIndexes = new Set();
1155
+ for (const feature of singletonFeatures) {
1156
+ const index = feature.properties?.['index'];
1157
+ if (typeof index === 'number') {
1158
+ singletonIndexes.add(index);
1159
+ }
1160
+ }
1161
+ this.clearMarkers();
1162
+ const markers = this.markers();
1163
+ for (const index of singletonIndexes) {
1164
+ const markerData = markers[index];
1165
+ if (markerData) {
1166
+ this.addMarkerToMap(markerData);
1167
+ }
1168
+ }
1169
+ // Clusters reales (punto 1, a11y): además del círculo WebGL que pinta
1170
+ // `CLUSTER_CIRCLE_LAYER_ID`, cada uno recibe acá un marker DOM real —
1171
+ // ver `addClusterMarkerToMap()` sobre por qué la capa WebGL sola no
1172
+ // alcanza (WCAG 2.1.1, sin nodo DOM no hay foco de teclado posible).
1173
+ const clusterFeatures = this.map.querySourceFeatures(MARKER_CLUSTER_SOURCE_ID, {
1174
+ filter: ['has', 'point_count'],
1175
+ });
1176
+ const seenClusterIds = new Set();
1177
+ this.clearClusterMarkers();
1178
+ for (const feature of clusterFeatures) {
1179
+ const clusterId = feature.properties?.['cluster_id'];
1180
+ const pointCount = feature.properties?.['point_count'];
1181
+ const geometry = feature.geometry;
1182
+ if (typeof clusterId !== 'number' ||
1183
+ typeof pointCount !== 'number' ||
1184
+ geometry?.type !== 'Point') {
1185
+ continue;
1186
+ }
1187
+ if (seenClusterIds.has(clusterId)) {
1188
+ continue;
1189
+ }
1190
+ seenClusterIds.add(clusterId);
1191
+ this.addClusterMarkerToMap(clusterId, geometry.coordinates, pointCount);
1192
+ }
1193
+ }
1194
+ /**
1195
+ * Marker DOM real sobre un cluster (punto 1, a11y) — MISMO patrón de
1196
+ * wiring que `addMarkerToMap()` (`role="button"`, `tabindex="0"`,
1197
+ * `click`+`keydown` de Enter/Space), aplicado acá porque el círculo que
1198
+ * pinta `CLUSTER_CIRCLE_LAYER_ID` es una capa WebGL sin nodo DOM propio
1199
+ * —inalcanzable por teclado (WCAG SC 2.1.1 Keyboard). `mapboxgl.Marker`
1200
+ * posiciona y reproyecta este `<div>` solo en cada pan/zoom, igual que ya
1201
+ * hace con cualquier marker individual — no hace falta reposicionarlo a
1202
+ * mano como sí necesita el popup resumen (ver `repositionClusterPopup()`).
1203
+ *
1204
+ * Sin `kf-icon`/texto propio adentro a propósito: el círculo+conteo que ya
1205
+ * pinta la capa WebGL sigue siendo la representación visual (mismo
1206
+ * `'circle-radius'`, ver `clusterMarkerRadiusPx()`); este `<div>` es
1207
+ * transparente y se superpone exacto encima, aportando SOLO la
1208
+ * interactividad real que la capa WebGL no puede tener.
1209
+ */
1210
+ addClusterMarkerToMap(clusterId, coordinates, pointCount) {
1211
+ if (!this.map) {
1212
+ return;
1213
+ }
1214
+ const diameter = clusterMarkerRadiusPx(pointCount) * 2;
1215
+ const element = this.document.createElement('div');
1216
+ element.className = 'kf-map__cluster-marker';
1217
+ element.style.width = `${diameter}px`;
1218
+ element.style.height = `${diameter}px`;
1219
+ element.setAttribute('role', 'button');
1220
+ element.setAttribute('tabindex', '0');
1221
+ element.setAttribute('aria-label', `${pointCount} puntos agrupados`);
1222
+ const activate = () => this.activateCluster(clusterId, coordinates, pointCount);
1223
+ element.addEventListener('click', activate);
1224
+ element.addEventListener('keydown', (event) => {
1225
+ if (event.key === 'Enter' || event.key === ' ') {
1226
+ event.preventDefault();
1227
+ activate();
1228
+ }
1229
+ });
1230
+ const marker = new Marker({ element }).setLngLat(coordinates).addTo(this.map);
1231
+ this.clusterMarkerHandles.push(marker);
1232
+ }
1233
+ clearClusterMarkers() {
1234
+ for (const marker of this.clusterMarkerHandles) {
1235
+ marker.remove();
1236
+ }
1237
+ this.clusterMarkerHandles = [];
1238
+ }
1239
+ /**
1240
+ * Activación de un cluster real (click O Enter/Space sobre su marker DOM,
1241
+ * ver `addClusterMarkerToMap()`): ANTES hacía zoom directo
1242
+ * (`getClusterExpansionZoom()` + `easeTo`) — ahora abre el popup resumen
1243
+ * (`renderClusterPopup()`) con la lista de tickets agrupados, y ESE zoom
1244
+ * pasa a ser la acción explícita "Acercar para separar" dentro del popup
1245
+ * (ver `expandCluster()`).
1246
+ */
1247
+ activateCluster(clusterId, coordinates, pointCount) {
1248
+ const map = this.map;
1249
+ if (!map) {
1250
+ return;
1251
+ }
1252
+ const source = map.getSource(MARKER_CLUSTER_SOURCE_ID);
1253
+ // `pointCount` como `limit`: trae TODOS los leaves del cluster, nunca
1254
+ // los corta en silencio (ver JSDoc de `renderClusterPopup()` sobre el
1255
+ // scroll interno para cuando son muchos) — mismo patrón que documenta
1256
+ // el propio Mapbox GL JS para este método.
1257
+ source?.getClusterLeaves(clusterId, pointCount, 0, (error, leaves) => {
1258
+ if (error || !leaves) {
1259
+ return;
1260
+ }
1261
+ this.renderClusterPopup(clusterId, coordinates, leaves);
1262
+ });
1263
+ }
1264
+ /** Dispara el zoom que antes hacía el click directo sobre el círculo —
1265
+ * ahora vive detrás del botón "Acercar para separar" del popup resumen. */
1266
+ expandCluster(clusterId, coordinates) {
1267
+ const map = this.map;
1268
+ if (!map) {
1269
+ return;
1270
+ }
1271
+ const source = map.getSource(MARKER_CLUSTER_SOURCE_ID);
1272
+ source?.getClusterExpansionZoom(clusterId, (error, zoom) => {
1273
+ if (error || typeof zoom !== 'number') {
1274
+ return;
1275
+ }
1276
+ map.easeTo({
1277
+ center: coordinates,
1278
+ zoom,
1279
+ duration: this.cameraDuration(CAMERA_DURATION_MS.recenter),
1280
+ });
1281
+ });
1282
+ }
1283
+ /**
1284
+ * `index` identifica de vuelta al `MapMarker` de origen (ver
1285
+ * `syncClusterMarkers()`); `title`/`description`/`markerId` son las
1286
+ * mismas properties que ya arma `buildMarkerPopup()` (folio/cliente/id
1287
+ * del caso real) — se serializan acá TAMBIÉN para que
1288
+ * `getClusterLeaves()` pueda listarlas en el popup resumen sin tener que
1289
+ * ir a buscar cada `MapMarker` original por separado. Los campos son
1290
+ * `string` (no `undefined`): las properties de una `Feature` de GeoJSON
1291
+ * no viajan bien como `undefined` — `''` significa "sin dato" en los 3.
1292
+ */
1293
+ markersToFeatureCollection() {
1294
+ return {
1295
+ type: 'FeatureCollection',
1296
+ features: this.markers().map((marker, index) => ({
1297
+ type: 'Feature',
1298
+ properties: {
1299
+ index,
1300
+ tone: marker.tone ?? 'neutral',
1301
+ title: marker.popup?.title ?? '',
1302
+ description: marker.popup?.description ?? '',
1303
+ markerId: marker.id ?? '',
1304
+ },
1305
+ geometry: { type: 'Point', coordinates: this.toLngLat(marker.position) },
1306
+ })),
1307
+ };
1308
+ }
1309
+ clearClusterLayers() {
1310
+ if (!this.map) {
1311
+ return;
1312
+ }
1313
+ // Las 2 capas antes que la fuente: Mapbox no deja sacar una fuente
1314
+ // mientras alguna capa todavía la referencia.
1315
+ for (const layerId of [CLUSTER_CIRCLE_LAYER_ID, CLUSTER_COUNT_LAYER_ID]) {
1316
+ if (this.map.getLayer(layerId)) {
1317
+ this.map.removeLayer(layerId);
1318
+ }
1319
+ }
1320
+ if (this.map.getSource(MARKER_CLUSTER_SOURCE_ID)) {
1321
+ this.map.removeSource(MARKER_CLUSTER_SOURCE_ID);
1322
+ }
1323
+ }
1324
+ /**
1325
+ * Popup resumen de un cluster real (punto 4) — a diferencia del popup de
1326
+ * marker individual (siempre en el DOM, `:hover`/`:focus-within` puro),
1327
+ * este no cuelga de ningún elemento con estado hover propio: lo abre un
1328
+ * click sobre una capa WebGL sin DOM, así que se crea/destruye a demanda
1329
+ * y su visibilidad la maneja JS, no CSS. Se agrega como hijo del HOST
1330
+ * (`kf-map`, no `.kf-map__container`): ambos comparten exactamente la
1331
+ * misma caja (`.kf-map__container` es `width/height:100%` sin padding/
1332
+ * borde dentro de un host `position:relative`), así que las coordenadas
1333
+ * de `map.project()` (relativas al contenedor) valen también acá — mismo
1334
+ * ancestro posicionado que ya usa `.kf-map__overlay`.
1335
+ *
1336
+ * `title`/`description` de cada leaf son `popup.title`/`popup.description`
1337
+ * del `MapMarker` original (folio/cliente en el caso real) — ver
1338
+ * `markersToFeatureCollection()`. El punto de color reusa `tone`, MISMO
1339
+ * criterio que el punto de color del popup individual (ver JSDoc de
1340
+ * `buildMarkerPopup()`): no se inventa un texto de "estado" que la
1341
+ * librería no tiene, se reusa la señal de color que sí existe.
1342
+ */
1343
+ renderClusterPopup(clusterId, coordinates, leaves) {
1344
+ this.closeClusterPopup();
1345
+ if (!this.map) {
1346
+ return;
1347
+ }
1348
+ const card = this.document.createElement('div');
1349
+ card.className = 'kf-map__cluster-popup';
1350
+ const header = this.document.createElement('div');
1351
+ header.className = 'kf-map__cluster-popup-header';
1352
+ const title = this.document.createElement('span');
1353
+ title.className = 'kf-map__cluster-popup-title';
1354
+ title.textContent = `${leaves.length} puntos agrupados`;
1355
+ const closeButton = this.document.createElement('button');
1356
+ closeButton.type = 'button';
1357
+ closeButton.className = 'kf-map__cluster-popup-close';
1358
+ closeButton.setAttribute('aria-label', 'Cerrar');
1359
+ const closeIconRef = createComponent(Icon, { environmentInjector: this.environmentInjector });
1360
+ closeIconRef.setInput('name', 'x');
1361
+ closeIconRef.setInput('size', 'sm');
1362
+ closeIconRef.changeDetectorRef.detectChanges();
1363
+ this.clusterPopupIconRefs.push(closeIconRef);
1364
+ closeButton.appendChild(closeIconRef.location.nativeElement);
1365
+ closeButton.addEventListener('click', () => this.closeClusterPopup());
1366
+ header.append(title, closeButton);
1367
+ card.appendChild(header);
1368
+ // `<ul>`/`<li>`: es una lista real de tickets, no párrafos sueltos —
1369
+ // `max-height`+`overflow-y:auto` en `MapView.css` resuelve el scroll
1370
+ // interno cuando hay más leaves de los que entran cómodos, en vez de
1371
+ // cortar la lista en silencio (ver JSDoc de `handleClusterClick()`
1372
+ // sobre por qué `getClusterLeaves` ya trae TODOS los leaves).
1373
+ const list = this.document.createElement('ul');
1374
+ list.className = 'kf-map__cluster-popup-list';
1375
+ for (const leaf of leaves) {
1376
+ const properties = leaf.properties;
1377
+ const markerId = properties?.markerId;
1378
+ const tone = properties?.tone ?? 'neutral';
1379
+ const item = this.document.createElement('li');
1380
+ item.className = 'kf-map__cluster-popup-item';
1381
+ const dot = this.document.createElement('span');
1382
+ dot.className = `kf-map__cluster-popup-dot kf-map__cluster-popup-dot--${tone}`;
1383
+ dot.setAttribute('aria-hidden', 'true');
1384
+ const text = this.document.createElement('span');
1385
+ text.className = 'kf-map__cluster-popup-text';
1386
+ const itemTitle = this.document.createElement('span');
1387
+ itemTitle.className = 'kf-map__cluster-popup-item-title';
1388
+ itemTitle.textContent = properties?.title || 'Sin título';
1389
+ text.appendChild(itemTitle);
1390
+ if (properties?.description) {
1391
+ const itemDescription = this.document.createElement('span');
1392
+ itemDescription.className = 'kf-map__cluster-popup-item-description';
1393
+ itemDescription.textContent = properties.description;
1394
+ text.appendChild(itemDescription);
1395
+ }
1396
+ item.append(dot, text);
1397
+ // Mismo criterio que el marker individual (ver JSDoc de `MapMarker.id`):
1398
+ // sin `markerId` esta fila no tiene con qué identificar de vuelta la
1399
+ // selección en `markerSelect`, así que se queda sin ser clickeable.
1400
+ if (markerId) {
1401
+ item.classList.add('kf-map__cluster-popup-item--clickable');
1402
+ item.setAttribute('role', 'button');
1403
+ item.setAttribute('tabindex', '0');
1404
+ const select = () => {
1405
+ this.markerSelect.emit(markerId);
1406
+ this.closeClusterPopup();
1407
+ };
1408
+ item.addEventListener('click', select);
1409
+ item.addEventListener('keydown', (event) => {
1410
+ if (event.key === 'Enter' || event.key === ' ') {
1411
+ event.preventDefault();
1412
+ select();
1413
+ }
1414
+ });
1415
+ }
1416
+ list.appendChild(item);
1417
+ }
1418
+ card.appendChild(list);
1419
+ const footer = this.document.createElement('div');
1420
+ footer.className = 'kf-map__cluster-popup-footer';
1421
+ const expandButton = this.document.createElement('button');
1422
+ expandButton.type = 'button';
1423
+ expandButton.className = 'kf-map__cluster-popup-expand';
1424
+ const expandIconRef = createComponent(Icon, { environmentInjector: this.environmentInjector });
1425
+ expandIconRef.setInput('name', 'plus');
1426
+ expandIconRef.setInput('size', 'sm');
1427
+ expandIconRef.changeDetectorRef.detectChanges();
1428
+ this.clusterPopupIconRefs.push(expandIconRef);
1429
+ const expandLabel = this.document.createElement('span');
1430
+ expandLabel.textContent = 'Acercar para separar';
1431
+ expandButton.append(expandIconRef.location.nativeElement, expandLabel);
1432
+ expandButton.addEventListener('click', () => {
1433
+ this.expandCluster(clusterId, coordinates);
1434
+ this.closeClusterPopup();
1435
+ });
1436
+ footer.appendChild(expandButton);
1437
+ card.appendChild(footer);
1438
+ this.hostElementRef.nativeElement.appendChild(card);
1439
+ this.clusterPopupElement = card;
1440
+ this.clusterPopupCoordinates = coordinates;
1441
+ this.repositionClusterPopup();
1442
+ }
1443
+ /** Reproyecta el popup de cluster contra pantalla — se llama en cada
1444
+ * `'move'` (ver `initializeMap()`): el punto geográfico no se mueve, pero
1445
+ * su posición EN PANTALLA sí cuando se hace pan. Mismo criterio de
1446
+ * recorte de borde que `positionMarkerPopup()` (punto 2), pero en
1447
+ * píxeles calculados a mano en vez de clases CSS: este popup no cuelga
1448
+ * de un ancestro posicionado por CSS relativo al marker, su posición
1449
+ * ENTERA viene de `map.project()`. */
1450
+ repositionClusterPopup() {
1451
+ if (!this.map || !this.clusterPopupElement || !this.clusterPopupCoordinates) {
1452
+ return;
1453
+ }
1454
+ const point = this.map.project(this.clusterPopupCoordinates);
1455
+ const containerRect = this.containerRef().nativeElement.getBoundingClientRect();
1456
+ const popupRect = this.clusterPopupElement.getBoundingClientRect();
1457
+ const maxLeft = Math.max(containerRect.width - popupRect.width - POPUP_EDGE_MARGIN_PX, POPUP_EDGE_MARGIN_PX);
1458
+ const left = Math.min(Math.max(point.x - popupRect.width / 2, POPUP_EDGE_MARGIN_PX), maxLeft);
1459
+ const fitsAbove = point.y - popupRect.height - CLUSTER_POPUP_GAP_PX >= POPUP_EDGE_MARGIN_PX;
1460
+ const top = fitsAbove
1461
+ ? point.y - popupRect.height - CLUSTER_POPUP_GAP_PX
1462
+ : point.y + CLUSTER_POPUP_GAP_PX;
1463
+ this.clusterPopupElement.style.left = `${left}px`;
1464
+ this.clusterPopupElement.style.top = `${Math.max(top, POPUP_EDGE_MARGIN_PX)}px`;
1465
+ }
1466
+ closeClusterPopup() {
1467
+ this.clusterPopupElement?.remove();
1468
+ this.clusterPopupElement = null;
1469
+ this.clusterPopupCoordinates = null;
1470
+ for (const iconRef of this.clusterPopupIconRefs) {
1471
+ iconRef.destroy();
1472
+ }
1473
+ this.clusterPopupIconRefs = [];
1474
+ }
1475
+ syncRoute() {
1476
+ if (!this.map || !this.map.isStyleLoaded()) {
1477
+ return;
1478
+ }
1479
+ if (this.map.getLayer(ROUTE_LAYER_ID)) {
1480
+ this.map.removeLayer(ROUTE_LAYER_ID);
1481
+ }
1482
+ if (this.map.getSource(ROUTE_SOURCE_ID)) {
1483
+ this.map.removeSource(ROUTE_SOURCE_ID);
1484
+ }
1485
+ const points = this.route();
1486
+ if (!points || points.length < 2) {
1487
+ return;
1488
+ }
1489
+ this.map.addSource(ROUTE_SOURCE_ID, {
1490
+ type: 'geojson',
1491
+ data: {
1492
+ type: 'Feature',
1493
+ properties: {},
1494
+ geometry: {
1495
+ type: 'LineString',
1496
+ coordinates: points.map((point) => this.toLngLat(point)),
1497
+ },
1498
+ },
1499
+ });
1500
+ this.map.addLayer({
1501
+ id: ROUTE_LAYER_ID,
1502
+ type: 'line',
1503
+ source: ROUTE_SOURCE_ID,
1504
+ layout: { 'line-cap': 'round', 'line-join': 'round' },
1505
+ paint: {
1506
+ // El paint de Mapbox corre en WebGL, fuera del árbol CSS — no
1507
+ // puede resolver `var(--kf-color-primary)` directo, así que el
1508
+ // valor YA COMPUTADO se lee del DOM en tiempo de ejecución. Sigue
1509
+ // siendo el token quien decide el color: nunca se hardcodea un hex.
1510
+ 'line-color': this.resolveColorToken('--kf-color-primary'),
1511
+ 'line-width': 3,
1512
+ },
1513
+ });
1514
+ }
1515
+ resolveColorToken(name) {
1516
+ return getComputedStyle(this.document.documentElement).getPropertyValue(name).trim();
1517
+ }
1518
+ // ---------- Wiring de `kf-map-controls` — ver `MapControls.ts` ----------
1519
+ // Cada handler es la única pieza que sabe CÓMO se resuelve una acción
1520
+ // sobre el `Map` real; `MapControls` solo reporta QUÉ se pidió.
1521
+ handleZoomIn() {
1522
+ this.map?.zoomIn({ duration: this.cameraDuration(CAMERA_DURATION_MS.zoomStep) });
1523
+ }
1524
+ handleZoomOut() {
1525
+ this.map?.zoomOut({ duration: this.cameraDuration(CAMERA_DURATION_MS.zoomStep) });
1526
+ }
1527
+ /** Reencuadra a demanda, ignorando `fitToMarkers()` (tiene sentido pedirlo
1528
+ * justo cuando `fitToMarkers` está en `false`) y sin apagar `follow`: si
1529
+ * está activo, la próxima posición nueva vuelve a pegar la cámara al
1530
+ * marker `live` — este reencuadre es una corrección puntual, no un modo. */
1531
+ handleRecenter() {
1532
+ if (!this.map) {
1533
+ return;
1534
+ }
1535
+ const bounds = this.computeBounds();
1536
+ if (bounds) {
1537
+ this.frameBounds(bounds, CAMERA_DURATION_MS.recenter);
1538
+ return;
1539
+ }
1540
+ this.map.easeTo({
1541
+ center: this.toLngLat(this.center() ?? DEFAULT_CENTER),
1542
+ zoom: this.zoom() ?? DEFAULT_ZOOM,
1543
+ duration: this.cameraDuration(CAMERA_DURATION_MS.recenter),
1544
+ });
1545
+ }
1546
+ handleFollowToggle() {
1547
+ this.followActive.update((value) => !value);
1548
+ this.syncCamera();
1549
+ }
1550
+ handleClusterToggle() {
1551
+ this.clusterActive.update((value) => !value);
1552
+ this.syncMarkers();
1553
+ }
1554
+ /** Reemplaza al viejo ciclado de un click (`handleStyleCycle`): la
1555
+ * elección real llega desde `MapThemePicker`, vía `MapControls`. */
1556
+ handleThemeSelect(theme) {
1557
+ this.userTheme.set(theme);
1558
+ }
1559
+ handleFullscreenToggle() {
1560
+ const host = this.hostElementRef.nativeElement;
1561
+ if (typeof host.requestFullscreen !== 'function') {
1562
+ return;
1563
+ }
1564
+ if (this.document.fullscreenElement === host) {
1565
+ void this.document.exitFullscreen();
1566
+ }
1567
+ else {
1568
+ void host.requestFullscreen();
1569
+ }
1570
+ }
1571
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: MapView, deps: [], target: i0.ɵɵFactoryTarget.Component });
1572
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.0", type: MapView, isStandalone: true, selector: "kf-map", inputs: { size: { classPropertyName: "size", publicName: "size", isSignal: true, isRequired: false, transformFunction: null }, variant: { classPropertyName: "variant", publicName: "variant", isSignal: true, isRequired: false, transformFunction: null }, markers: { classPropertyName: "markers", publicName: "markers", isSignal: true, isRequired: false, transformFunction: null }, route: { classPropertyName: "route", publicName: "route", isSignal: true, isRequired: false, transformFunction: null }, fitToMarkers: { classPropertyName: "fitToMarkers", publicName: "fitToMarkers", isSignal: true, isRequired: false, transformFunction: null }, center: { classPropertyName: "center", publicName: "center", isSignal: true, isRequired: false, transformFunction: null }, zoom: { classPropertyName: "zoom", publicName: "zoom", isSignal: true, isRequired: false, transformFunction: null }, theme: { classPropertyName: "theme", publicName: "theme", isSignal: true, isRequired: false, transformFunction: null }, controls: { classPropertyName: "controls", publicName: "controls", isSignal: true, isRequired: false, transformFunction: null }, label: { classPropertyName: "label", publicName: "label", isSignal: true, isRequired: true, transformFunction: null } }, outputs: { markerSelect: "markerSelect", markerAction: "markerAction" }, host: { properties: { "class.kf-map--sm": "size() === 'sm'", "class.kf-map--md": "size() === 'md'", "class.kf-map--lg": "size() === 'lg'", "class.kf-map--full": "size() === 'full'" } }, viewQueries: [{ propertyName: "containerRef", first: true, predicate: ["mapContainer"], descendants: true, isSignal: true }], ngImport: i0, template: "<div\n #mapContainer\n class=\"kf-map__container\"\n [attr.role]=\"containerRole()\"\n [attr.aria-label]=\"label()\"\n [attr.tabindex]=\"isPreview() ? -1 : null\"\n></div>\n\n@if (status() === 'loading') {\n <div class=\"kf-map__overlay\" aria-live=\"polite\">\n <kf-spinner label=\"Cargando mapa\u2026\" />\n </div>\n} @else if (status() === 'error') {\n <div class=\"kf-map__overlay kf-map__overlay--error\" role=\"alert\">\n <kf-icon name=\"alert-triangle\" size=\"lg\" />\n <kf-text as=\"p\" variant=\"body\" tone=\"error\">No pudimos cargar el mapa</kf-text>\n </div>\n}\n\n@if (showControls()) {\n <kf-map-controls\n class=\"kf-map__controls\"\n [controls]=\"effectiveControls()\"\n [clusterActive]=\"clusterActive()\"\n [followActive]=\"followActive()\"\n [canFollow]=\"hasLiveMarker()\"\n [isFullscreen]=\"isFullscreen()\"\n [styleLabel]=\"styleLabel()\"\n [activeTheme]=\"activeTheme()\"\n (zoomIn)=\"handleZoomIn()\"\n (zoomOut)=\"handleZoomOut()\"\n (recenter)=\"handleRecenter()\"\n (followToggle)=\"handleFollowToggle()\"\n (clusterToggle)=\"handleClusterToggle()\"\n (themeSelect)=\"handleThemeSelect($event)\"\n (fullscreenToggle)=\"handleFullscreenToggle()\"\n />\n}\n", styles: ["kf-map{display:block;position:relative;width:100%;border-radius:var(--kf-radius-md);overflow:hidden;background:var(--kf-color-gray-100)}kf-map.kf-map--sm{height:200px}kf-map.kf-map--md{height:320px}kf-map.kf-map--lg{height:480px}kf-map.kf-map--full{height:100%}kf-map:fullscreen,kf-map:-webkit-full-screen{width:100vw;height:100vh;border-radius:0}.kf-map__container{width:100%;height:100%}.kf-map__overlay{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kf-space-sm);background:var(--kf-color-gray-100);text-align:center;padding:var(--kf-space-md);box-sizing:border-box}.kf-map__overlay--error{background:var(--kf-color-error-soft)}.kf-map__controls{position:absolute;top:var(--kf-space-sm);right:var(--kf-space-sm);z-index:3}.kf-map__marker{position:absolute;top:0;left:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:3px solid var(--kf-color-white);box-shadow:var(--kf-shadow-md);color:var(--kf-color-white);z-index:1}.kf-map__marker:hover,.kf-map__marker:focus-within{z-index:3}.kf-map__marker--neutral{background:var(--kf-color-gray-700)}.kf-map__marker--ok{background:var(--kf-color-success)}.kf-map__marker--warn{background:var(--kf-color-warning)}.kf-map__marker--error{background:var(--kf-color-error)}.kf-map__marker--info{background:var(--kf-color-info)}.kf-map__marker--live:before{content:\"\";position:absolute;inset:-8px;border-radius:50%;background:var(--kf-color-white);opacity:.45;animation:kf-map-marker-pulse 1.8s ease-out infinite}@keyframes kf-map-marker-pulse{0%{transform:scale(.6);opacity:.45}to{transform:scale(1.6);opacity:0}}.kf-map__marker--clickable{cursor:pointer}.kf-map__marker--clickable:after{content:\"\";position:absolute;inset:-6px;border-radius:50%;transition:background-color .12s ease}.kf-map__marker--clickable:hover:after{background-color:color-mix(in srgb,var(--kf-color-ink) 12%,transparent)}.kf-map__marker--clickable:active{filter:brightness(.85)}.kf-map__marker--clickable:focus-visible{outline:none;box-shadow:0 0 0 2px var(--kf-color-white),0 0 0 4px var(--kf-color-ink)}@media(prefers-reduced-motion:reduce){.kf-map__marker--clickable:after{transition:none}.kf-map__marker--live:before{animation:none}}.kf-map__cluster-marker{position:absolute;top:0;left:0;border-radius:50%;cursor:pointer}.kf-map__cluster-marker:focus-visible{outline:none;box-shadow:0 0 0 2px var(--kf-color-white),0 0 0 4px var(--kf-color-ink)}.kf-map__marker-popup{--kf-map-popup-tx: -50%;--kf-map-popup-ty: 4px;position:absolute;bottom:calc(100% + 12px);left:50%;transform:translate(var(--kf-map-popup-tx),var(--kf-map-popup-ty));display:flex;flex-direction:column;gap:var(--kf-space-xs);min-width:240px;max-width:320px;background:var(--kf-color-white);border:1px solid var(--kf-color-border);border-radius:var(--kf-radius-md);box-shadow:var(--kf-shadow-md);padding:var(--kf-space-md);text-align:left;opacity:0;pointer-events:none;transition:opacity .14s ease,transform .14s ease;z-index:4}.kf-map__marker-popup:after{content:\"\";position:absolute;top:100%;left:50%;transform:translate(-50%);border:7px solid transparent;border-top-color:var(--kf-color-white)}.kf-map__marker:hover .kf-map__marker-popup,.kf-map__marker:focus-within .kf-map__marker-popup{opacity:1;transform:translate(var(--kf-map-popup-tx));pointer-events:auto}.kf-map__marker-popup--align-start{left:0;--kf-map-popup-tx: 0%}.kf-map__marker-popup--align-end{left:auto;right:0;--kf-map-popup-tx: 0%}.kf-map__marker-popup--below{bottom:auto;top:calc(100% + 12px);--kf-map-popup-ty: -4px}.kf-map__marker-popup--below:after{top:auto;bottom:100%;border-top-color:transparent;border-bottom-color:var(--kf-color-white)}.kf-map__marker-popup-heading{display:flex;align-items:center;gap:var(--kf-space-xs)}.kf-map__marker-popup-tone{inline-size:8px;block-size:8px;border-radius:var(--kf-radius-pill);flex:none}.kf-map__marker-popup-tone--neutral{background:var(--kf-color-gray-700)}.kf-map__marker-popup-tone--ok{background:var(--kf-color-success)}.kf-map__marker-popup-tone--warn{background:var(--kf-color-warning)}.kf-map__marker-popup-tone--error{background:var(--kf-color-error)}.kf-map__marker-popup-tone--info{background:var(--kf-color-info)}.kf-map__marker-popup-title{font-family:var(--kf-font-mono);font-weight:700;font-size:.9375rem;line-height:1.25;color:var(--kf-color-primary)}.kf-map__marker-popup-description{font-size:.8125rem;line-height:1.4;color:var(--kf-color-text)}.kf-map__marker-popup-details{display:flex;flex-direction:column;gap:4px;margin:2px 0 0;padding:var(--kf-space-xs) 0 0;border-top:1px solid var(--kf-color-border)}.kf-map__marker-popup-detail{display:flex;align-items:baseline;justify-content:space-between;gap:var(--kf-space-sm);margin:0}.kf-map__marker-popup-detail-label{font-size:.625rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--kf-color-text-muted);white-space:nowrap}.kf-map__marker-popup-detail-value{margin:0;font-size:.75rem;font-weight:600;color:var(--kf-color-text);text-align:right}.kf-map__marker-popup-actions{display:flex;gap:var(--kf-space-xs);margin-top:2px}.kf-map__marker-popup-action{flex:1;min-width:0;display:inline-flex;align-items:center;justify-content:center;gap:4px;font:inherit;font-size:.75rem;font-weight:700;border:1px solid transparent;border-radius:var(--kf-radius-sm);padding:6px 10px;cursor:pointer;transition:background .12s ease,border-color .12s ease,color .12s ease}.kf-map__marker-popup-action-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.kf-map__marker-popup-action--primary{color:var(--kf-color-white);background:var(--kf-color-primary);border-color:var(--kf-color-primary)}.kf-map__marker-popup-action--primary:hover{background:var(--kf-color-primary-hover);border-color:var(--kf-color-primary-hover)}.kf-map__marker-popup-action--ghost{color:var(--kf-color-primary);background:transparent}.kf-map__marker-popup-action--ghost:hover{background:var(--kf-color-primary-soft)}.kf-map__marker-popup-action:focus-visible{outline:none;box-shadow:var(--kf-shadow-focus)}.kf-map__marker-popup-action svg,.kf-map__cluster-popup-close svg,.kf-map__cluster-popup-expand svg{width:14px;height:14px;flex:none}@media(prefers-reduced-motion:reduce){.kf-map__marker-popup,.kf-map__marker-popup-action{transition:none}}.kf-map__cluster-popup{position:absolute;display:flex;flex-direction:column;width:280px;max-width:calc(100% - 2 * var(--kf-space-sm));background:var(--kf-color-white);border:1px solid var(--kf-color-border);border-radius:var(--kf-radius-md);box-shadow:var(--kf-shadow-md);overflow:hidden;z-index:5}.kf-map__cluster-popup-header{display:flex;align-items:center;justify-content:space-between;gap:var(--kf-space-sm);padding:var(--kf-space-sm) var(--kf-space-md);border-bottom:1px solid var(--kf-color-border)}.kf-map__cluster-popup-title{font-weight:700;font-size:.8125rem;color:var(--kf-color-text)}.kf-map__cluster-popup-close{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;border-radius:var(--kf-radius-sm);background:transparent;color:var(--kf-color-text-muted);cursor:pointer;flex:none}.kf-map__cluster-popup-close:hover{background:var(--kf-color-gray-100);color:var(--kf-color-text)}.kf-map__cluster-popup-close:focus-visible{outline:none;box-shadow:var(--kf-shadow-focus)}.kf-map__cluster-popup-list{display:flex;flex-direction:column;max-height:240px;overflow-y:auto;margin:0;padding:var(--kf-space-xs) 0;list-style:none}.kf-map__cluster-popup-item{display:flex;align-items:center;gap:var(--kf-space-sm);padding:var(--kf-space-xs) var(--kf-space-md)}.kf-map__cluster-popup-item--clickable{cursor:pointer;transition:background .12s ease}.kf-map__cluster-popup-item--clickable:hover,.kf-map__cluster-popup-item--clickable:focus-visible{background:var(--kf-color-surface);outline:none}.kf-map__cluster-popup-dot{inline-size:8px;block-size:8px;border-radius:var(--kf-radius-pill);flex:none}.kf-map__cluster-popup-dot--neutral{background:var(--kf-color-gray-700)}.kf-map__cluster-popup-dot--ok{background:var(--kf-color-success)}.kf-map__cluster-popup-dot--warn{background:var(--kf-color-warning)}.kf-map__cluster-popup-dot--error{background:var(--kf-color-error)}.kf-map__cluster-popup-dot--info{background:var(--kf-color-info)}.kf-map__cluster-popup-text{display:flex;flex-direction:column;min-width:0;gap:1px}.kf-map__cluster-popup-item-title{font-weight:600;font-size:.8125rem;color:var(--kf-color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.kf-map__cluster-popup-item-description{font-size:.75rem;color:var(--kf-color-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.kf-map__cluster-popup-footer{padding:var(--kf-space-sm) var(--kf-space-md);border-top:1px solid var(--kf-color-border)}.kf-map__cluster-popup-expand{display:inline-flex;align-items:center;justify-content:center;gap:4px;width:100%;font:inherit;font-size:.75rem;font-weight:700;color:var(--kf-color-primary);background:transparent;border:1px solid var(--kf-color-border-strong);border-radius:var(--kf-radius-sm);padding:6px 10px;cursor:pointer;transition:background .12s ease}.kf-map__cluster-popup-expand:hover{background:var(--kf-color-primary-soft)}.kf-map__cluster-popup-expand:focus-visible{outline:none;box-shadow:var(--kf-shadow-focus)}@media(prefers-reduced-motion:reduce){.kf-map__cluster-popup-item--clickable,.kf-map__cluster-popup-expand{transition:none}}\n"], dependencies: [{ kind: "component", type: MapControls, selector: "kf-map-controls", inputs: ["controls", "clusterActive", "followActive", "canFollow", "isFullscreen", "styleLabel", "activeTheme"], outputs: ["zoomIn", "zoomOut", "recenter", "followToggle", "clusterToggle", "themeSelect", "fullscreenToggle"] }, { kind: "component", type: Spinner, selector: "kf-spinner", inputs: ["size", "label", "onDark"] }, { kind: "component", type: Icon, selector: "kf-icon", inputs: ["name", "size", "rotate", "filled"] }, { kind: "component", type: Text, selector: "kf-text", inputs: ["as", "variant", "tone"] }], encapsulation: i0.ViewEncapsulation.None });
1573
+ }
1574
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.0", ngImport: i0, type: MapView, decorators: [{
1575
+ type: Component,
1576
+ args: [{ selector: 'kf-map', imports: [MapControls, Spinner, Icon, Text], encapsulation: ViewEncapsulation.None, host: {
1577
+ '[class.kf-map--sm]': "size() === 'sm'",
1578
+ '[class.kf-map--md]': "size() === 'md'",
1579
+ '[class.kf-map--lg]': "size() === 'lg'",
1580
+ '[class.kf-map--full]': "size() === 'full'",
1581
+ }, template: "<div\n #mapContainer\n class=\"kf-map__container\"\n [attr.role]=\"containerRole()\"\n [attr.aria-label]=\"label()\"\n [attr.tabindex]=\"isPreview() ? -1 : null\"\n></div>\n\n@if (status() === 'loading') {\n <div class=\"kf-map__overlay\" aria-live=\"polite\">\n <kf-spinner label=\"Cargando mapa\u2026\" />\n </div>\n} @else if (status() === 'error') {\n <div class=\"kf-map__overlay kf-map__overlay--error\" role=\"alert\">\n <kf-icon name=\"alert-triangle\" size=\"lg\" />\n <kf-text as=\"p\" variant=\"body\" tone=\"error\">No pudimos cargar el mapa</kf-text>\n </div>\n}\n\n@if (showControls()) {\n <kf-map-controls\n class=\"kf-map__controls\"\n [controls]=\"effectiveControls()\"\n [clusterActive]=\"clusterActive()\"\n [followActive]=\"followActive()\"\n [canFollow]=\"hasLiveMarker()\"\n [isFullscreen]=\"isFullscreen()\"\n [styleLabel]=\"styleLabel()\"\n [activeTheme]=\"activeTheme()\"\n (zoomIn)=\"handleZoomIn()\"\n (zoomOut)=\"handleZoomOut()\"\n (recenter)=\"handleRecenter()\"\n (followToggle)=\"handleFollowToggle()\"\n (clusterToggle)=\"handleClusterToggle()\"\n (themeSelect)=\"handleThemeSelect($event)\"\n (fullscreenToggle)=\"handleFullscreenToggle()\"\n />\n}\n", styles: ["kf-map{display:block;position:relative;width:100%;border-radius:var(--kf-radius-md);overflow:hidden;background:var(--kf-color-gray-100)}kf-map.kf-map--sm{height:200px}kf-map.kf-map--md{height:320px}kf-map.kf-map--lg{height:480px}kf-map.kf-map--full{height:100%}kf-map:fullscreen,kf-map:-webkit-full-screen{width:100vw;height:100vh;border-radius:0}.kf-map__container{width:100%;height:100%}.kf-map__overlay{position:absolute;inset:0;z-index:2;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:var(--kf-space-sm);background:var(--kf-color-gray-100);text-align:center;padding:var(--kf-space-md);box-sizing:border-box}.kf-map__overlay--error{background:var(--kf-color-error-soft)}.kf-map__controls{position:absolute;top:var(--kf-space-sm);right:var(--kf-space-sm);z-index:3}.kf-map__marker{position:absolute;top:0;left:0;display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:50%;border:3px solid var(--kf-color-white);box-shadow:var(--kf-shadow-md);color:var(--kf-color-white);z-index:1}.kf-map__marker:hover,.kf-map__marker:focus-within{z-index:3}.kf-map__marker--neutral{background:var(--kf-color-gray-700)}.kf-map__marker--ok{background:var(--kf-color-success)}.kf-map__marker--warn{background:var(--kf-color-warning)}.kf-map__marker--error{background:var(--kf-color-error)}.kf-map__marker--info{background:var(--kf-color-info)}.kf-map__marker--live:before{content:\"\";position:absolute;inset:-8px;border-radius:50%;background:var(--kf-color-white);opacity:.45;animation:kf-map-marker-pulse 1.8s ease-out infinite}@keyframes kf-map-marker-pulse{0%{transform:scale(.6);opacity:.45}to{transform:scale(1.6);opacity:0}}.kf-map__marker--clickable{cursor:pointer}.kf-map__marker--clickable:after{content:\"\";position:absolute;inset:-6px;border-radius:50%;transition:background-color .12s ease}.kf-map__marker--clickable:hover:after{background-color:color-mix(in srgb,var(--kf-color-ink) 12%,transparent)}.kf-map__marker--clickable:active{filter:brightness(.85)}.kf-map__marker--clickable:focus-visible{outline:none;box-shadow:0 0 0 2px var(--kf-color-white),0 0 0 4px var(--kf-color-ink)}@media(prefers-reduced-motion:reduce){.kf-map__marker--clickable:after{transition:none}.kf-map__marker--live:before{animation:none}}.kf-map__cluster-marker{position:absolute;top:0;left:0;border-radius:50%;cursor:pointer}.kf-map__cluster-marker:focus-visible{outline:none;box-shadow:0 0 0 2px var(--kf-color-white),0 0 0 4px var(--kf-color-ink)}.kf-map__marker-popup{--kf-map-popup-tx: -50%;--kf-map-popup-ty: 4px;position:absolute;bottom:calc(100% + 12px);left:50%;transform:translate(var(--kf-map-popup-tx),var(--kf-map-popup-ty));display:flex;flex-direction:column;gap:var(--kf-space-xs);min-width:240px;max-width:320px;background:var(--kf-color-white);border:1px solid var(--kf-color-border);border-radius:var(--kf-radius-md);box-shadow:var(--kf-shadow-md);padding:var(--kf-space-md);text-align:left;opacity:0;pointer-events:none;transition:opacity .14s ease,transform .14s ease;z-index:4}.kf-map__marker-popup:after{content:\"\";position:absolute;top:100%;left:50%;transform:translate(-50%);border:7px solid transparent;border-top-color:var(--kf-color-white)}.kf-map__marker:hover .kf-map__marker-popup,.kf-map__marker:focus-within .kf-map__marker-popup{opacity:1;transform:translate(var(--kf-map-popup-tx));pointer-events:auto}.kf-map__marker-popup--align-start{left:0;--kf-map-popup-tx: 0%}.kf-map__marker-popup--align-end{left:auto;right:0;--kf-map-popup-tx: 0%}.kf-map__marker-popup--below{bottom:auto;top:calc(100% + 12px);--kf-map-popup-ty: -4px}.kf-map__marker-popup--below:after{top:auto;bottom:100%;border-top-color:transparent;border-bottom-color:var(--kf-color-white)}.kf-map__marker-popup-heading{display:flex;align-items:center;gap:var(--kf-space-xs)}.kf-map__marker-popup-tone{inline-size:8px;block-size:8px;border-radius:var(--kf-radius-pill);flex:none}.kf-map__marker-popup-tone--neutral{background:var(--kf-color-gray-700)}.kf-map__marker-popup-tone--ok{background:var(--kf-color-success)}.kf-map__marker-popup-tone--warn{background:var(--kf-color-warning)}.kf-map__marker-popup-tone--error{background:var(--kf-color-error)}.kf-map__marker-popup-tone--info{background:var(--kf-color-info)}.kf-map__marker-popup-title{font-family:var(--kf-font-mono);font-weight:700;font-size:.9375rem;line-height:1.25;color:var(--kf-color-primary)}.kf-map__marker-popup-description{font-size:.8125rem;line-height:1.4;color:var(--kf-color-text)}.kf-map__marker-popup-details{display:flex;flex-direction:column;gap:4px;margin:2px 0 0;padding:var(--kf-space-xs) 0 0;border-top:1px solid var(--kf-color-border)}.kf-map__marker-popup-detail{display:flex;align-items:baseline;justify-content:space-between;gap:var(--kf-space-sm);margin:0}.kf-map__marker-popup-detail-label{font-size:.625rem;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--kf-color-text-muted);white-space:nowrap}.kf-map__marker-popup-detail-value{margin:0;font-size:.75rem;font-weight:600;color:var(--kf-color-text);text-align:right}.kf-map__marker-popup-actions{display:flex;gap:var(--kf-space-xs);margin-top:2px}.kf-map__marker-popup-action{flex:1;min-width:0;display:inline-flex;align-items:center;justify-content:center;gap:4px;font:inherit;font-size:.75rem;font-weight:700;border:1px solid transparent;border-radius:var(--kf-radius-sm);padding:6px 10px;cursor:pointer;transition:background .12s ease,border-color .12s ease,color .12s ease}.kf-map__marker-popup-action-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.kf-map__marker-popup-action--primary{color:var(--kf-color-white);background:var(--kf-color-primary);border-color:var(--kf-color-primary)}.kf-map__marker-popup-action--primary:hover{background:var(--kf-color-primary-hover);border-color:var(--kf-color-primary-hover)}.kf-map__marker-popup-action--ghost{color:var(--kf-color-primary);background:transparent}.kf-map__marker-popup-action--ghost:hover{background:var(--kf-color-primary-soft)}.kf-map__marker-popup-action:focus-visible{outline:none;box-shadow:var(--kf-shadow-focus)}.kf-map__marker-popup-action svg,.kf-map__cluster-popup-close svg,.kf-map__cluster-popup-expand svg{width:14px;height:14px;flex:none}@media(prefers-reduced-motion:reduce){.kf-map__marker-popup,.kf-map__marker-popup-action{transition:none}}.kf-map__cluster-popup{position:absolute;display:flex;flex-direction:column;width:280px;max-width:calc(100% - 2 * var(--kf-space-sm));background:var(--kf-color-white);border:1px solid var(--kf-color-border);border-radius:var(--kf-radius-md);box-shadow:var(--kf-shadow-md);overflow:hidden;z-index:5}.kf-map__cluster-popup-header{display:flex;align-items:center;justify-content:space-between;gap:var(--kf-space-sm);padding:var(--kf-space-sm) var(--kf-space-md);border-bottom:1px solid var(--kf-color-border)}.kf-map__cluster-popup-title{font-weight:700;font-size:.8125rem;color:var(--kf-color-text)}.kf-map__cluster-popup-close{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;border:none;border-radius:var(--kf-radius-sm);background:transparent;color:var(--kf-color-text-muted);cursor:pointer;flex:none}.kf-map__cluster-popup-close:hover{background:var(--kf-color-gray-100);color:var(--kf-color-text)}.kf-map__cluster-popup-close:focus-visible{outline:none;box-shadow:var(--kf-shadow-focus)}.kf-map__cluster-popup-list{display:flex;flex-direction:column;max-height:240px;overflow-y:auto;margin:0;padding:var(--kf-space-xs) 0;list-style:none}.kf-map__cluster-popup-item{display:flex;align-items:center;gap:var(--kf-space-sm);padding:var(--kf-space-xs) var(--kf-space-md)}.kf-map__cluster-popup-item--clickable{cursor:pointer;transition:background .12s ease}.kf-map__cluster-popup-item--clickable:hover,.kf-map__cluster-popup-item--clickable:focus-visible{background:var(--kf-color-surface);outline:none}.kf-map__cluster-popup-dot{inline-size:8px;block-size:8px;border-radius:var(--kf-radius-pill);flex:none}.kf-map__cluster-popup-dot--neutral{background:var(--kf-color-gray-700)}.kf-map__cluster-popup-dot--ok{background:var(--kf-color-success)}.kf-map__cluster-popup-dot--warn{background:var(--kf-color-warning)}.kf-map__cluster-popup-dot--error{background:var(--kf-color-error)}.kf-map__cluster-popup-dot--info{background:var(--kf-color-info)}.kf-map__cluster-popup-text{display:flex;flex-direction:column;min-width:0;gap:1px}.kf-map__cluster-popup-item-title{font-weight:600;font-size:.8125rem;color:var(--kf-color-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.kf-map__cluster-popup-item-description{font-size:.75rem;color:var(--kf-color-text-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.kf-map__cluster-popup-footer{padding:var(--kf-space-sm) var(--kf-space-md);border-top:1px solid var(--kf-color-border)}.kf-map__cluster-popup-expand{display:inline-flex;align-items:center;justify-content:center;gap:4px;width:100%;font:inherit;font-size:.75rem;font-weight:700;color:var(--kf-color-primary);background:transparent;border:1px solid var(--kf-color-border-strong);border-radius:var(--kf-radius-sm);padding:6px 10px;cursor:pointer;transition:background .12s ease}.kf-map__cluster-popup-expand:hover{background:var(--kf-color-primary-soft)}.kf-map__cluster-popup-expand:focus-visible{outline:none;box-shadow:var(--kf-shadow-focus)}@media(prefers-reduced-motion:reduce){.kf-map__cluster-popup-item--clickable,.kf-map__cluster-popup-expand{transition:none}}\n"] }]
1582
+ }], ctorParameters: () => [], propDecorators: { size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], variant: [{ type: i0.Input, args: [{ isSignal: true, alias: "variant", required: false }] }], markers: [{ type: i0.Input, args: [{ isSignal: true, alias: "markers", required: false }] }], route: [{ type: i0.Input, args: [{ isSignal: true, alias: "route", required: false }] }], fitToMarkers: [{ type: i0.Input, args: [{ isSignal: true, alias: "fitToMarkers", required: false }] }], center: [{ type: i0.Input, args: [{ isSignal: true, alias: "center", required: false }] }], zoom: [{ type: i0.Input, args: [{ isSignal: true, alias: "zoom", required: false }] }], theme: [{ type: i0.Input, args: [{ isSignal: true, alias: "theme", required: false }] }], controls: [{ type: i0.Input, args: [{ isSignal: true, alias: "controls", required: false }] }], label: [{ type: i0.Input, args: [{ isSignal: true, alias: "label", required: true }] }], markerSelect: [{ type: i0.Output, args: ["markerSelect"] }], markerAction: [{ type: i0.Output, args: ["markerAction"] }], containerRef: [{ type: i0.ViewChild, args: ['mapContainer', { isSignal: true }] }] } });
1583
+
1584
+ /*
1585
+ * Public API Surface of ui/map — entry point secundario, `@kaufmann-lab/ui/map`.
1586
+ *
1587
+ * `kf-map`/`MapView` (y todo lo que depende de `mapbox-gl`) vivía en el
1588
+ * barrel principal (`projects/ui/src/public-api.ts`) hasta acá. Se movió a un
1589
+ * entry point secundario para que un consumidor que NO usa el mapa no
1590
+ * arrastre `mapbox-gl` a su bundle inicial solo por importar cualquier otro
1591
+ * símbolo de `@kaufmann-lab/ui` — confirmado con un build real de producción
1592
+ * de un consumidor sin `<kf-map>` en ningún componente: ni siquiera migrar
1593
+ * `mapbox-gl` a su subpath ESM (`mapbox-gl/esm`, ver el comentario en
1594
+ * `MapView.ts`) alcanzaba para que el tree-shaking de esbuild lo eliminara
1595
+ * del bundle inicial estando en el barrel único — 1.28 MB igual, con
1596
+ * `hd-main`/`standard-main`/`lite-main`/`debug`/`raster_array-main` (chunks
1597
+ * internos de `mapbox-gl`) apareciendo como lazy chunks de todos modos.
1598
+ *
1599
+ * Los archivos fuente viven acá (`./lib/map/`), no reexportados desde otro
1600
+ * directorio: ng-packagr calcula un `rootDir` propio POR entry point (el
1601
+ * directorio del `entryFile`) y rechaza cualquier archivo fuente importado
1602
+ * desde afuera de ese `rootDir` (`TS6059: File '...' is not under
1603
+ * 'rootDir'`) — cada entry point de Angular Package Format es su propio
1604
+ * proyecto TypeScript autocontenido, no una vista alternativa del mismo árbol
1605
+ * de archivos. `MapView.ts`/`MapControls.ts`/`MapThemePicker.ts` importan
1606
+ * `Icon`/`Spinner`/`Text`/`Button`/`Dropdown`/`DropdownPanel`/
1607
+ * `TooltipDirective` desde `'@kaufmann-lab/ui/core'` y
1608
+ * `handleArrowKeyNavigation` desde `'@kaufmann-lab/ui/shared'` (el paquete, no
1609
+ * una ruta relativa) — el patrón estándar para que un entry point secundario
1610
+ * reuse piezas de otro (mismo patrón que Angular Material: cada entry point
1611
+ * de `@angular/material/*` es autocontenido y consume `@angular/cdk` como
1612
+ * dependencia real, no con imports relativos cruzando paquetes). Nunca desde
1613
+ * la raíz `'@kaufmann-lab/ui'`: es un agregador que depende de todos los
1614
+ * entry points, no de vuelta.
1615
+ *
1616
+ * CAMBIO DE API PÚBLICA: cualquier consumidor que hoy importe `MapView`,
1617
+ * `MapControls`, `MapMarker`, `provideMapConfig`, `MAP_CONFIG`, `MapTheme`,
1618
+ * etc. desde `'@kaufmann-lab/ui'` tiene que migrar esos imports a
1619
+ * `'@kaufmann-lab/ui/map'` — ver `route-assistance-monorepo` (único
1620
+ * consumidor real de `kf-map` hoy), pendiente de migrar sus imports, no
1621
+ * hecho automáticamente por este cambio.
1622
+ *
1623
+ * `allowedNonPeerDependencies: ["mapbox-gl"]` sigue viviendo en el
1624
+ * `ng-package.json` del entry point PRIMARIO (`projects/ui/ng-package.json`),
1625
+ * no acá — ng-packagr solo lee esa clave del entry point primario
1626
+ * (`NgPackage.allowedNonPeerDependencies` en su código fuente hace
1627
+ * `this.primary.$get(...)`, nunca del secundario), aunque `mapbox-gl` ya no
1628
+ * lo importe el primario. Declararla acá también rompe el build: el schema
1629
+ * de un entry point SECUNDARIO no admite esa clave (`additionalProperties`).
1630
+ */
1631
+
1632
+ /**
1633
+ * Generated bundle index. Do not edit.
1634
+ */
1635
+
1636
+ export { MAP_CONFIG, MapControls, MapView, STYLE_BY_THEME, THEME_LABEL, THEME_ORDER, provideMapConfig };
1637
+ //# sourceMappingURL=kaufmann-lab-ui-map.mjs.map