@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.
- package/README.md +1 -1
- package/fesm2022/kaufmann-lab-ui-auth.mjs +322 -0
- package/fesm2022/kaufmann-lab-ui-auth.mjs.map +1 -0
- package/fesm2022/kaufmann-lab-ui-business.mjs +453 -0
- package/fesm2022/kaufmann-lab-ui-business.mjs.map +1 -0
- package/fesm2022/kaufmann-lab-ui-core.mjs +5906 -0
- package/fesm2022/kaufmann-lab-ui-core.mjs.map +1 -0
- package/fesm2022/kaufmann-lab-ui-map.mjs +1637 -0
- package/fesm2022/kaufmann-lab-ui-map.mjs.map +1 -0
- package/fesm2022/kaufmann-lab-ui-mobile.mjs +92 -0
- package/fesm2022/kaufmann-lab-ui-mobile.mjs.map +1 -0
- package/fesm2022/kaufmann-lab-ui-shared.mjs +269 -0
- package/fesm2022/kaufmann-lab-ui-shared.mjs.map +1 -0
- package/fesm2022/kaufmann-lab-ui.mjs +23 -8448
- package/fesm2022/kaufmann-lab-ui.mjs.map +1 -1
- package/kaufmann-lab-ui-0.1.18.tgz +0 -0
- package/package.json +25 -1
- package/types/kaufmann-lab-ui-auth.d.ts +240 -0
- package/types/kaufmann-lab-ui-business.d.ts +298 -0
- package/types/kaufmann-lab-ui-core.d.ts +3288 -0
- package/types/kaufmann-lab-ui-map.d.ts +631 -0
- package/types/kaufmann-lab-ui-mobile.d.ts +53 -0
- package/types/kaufmann-lab-ui-shared.d.ts +154 -0
- package/types/kaufmann-lab-ui.d.ts +5 -4620
- package/fesm2022/mapbox-gl-csp.worker.js +0 -6
|
@@ -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
|