/** * ct-map - Interactive map component using Leaflet * * Displays markers, circles, and polylines with bidirectional Cell reactivity. * Uses OpenStreetMap tiles (no API key required). */ import { html, PropertyValues } from "lit"; import { BaseElement } from "../../core/base-element.ts"; import { styles } from "./styles.ts"; import * as L from "leaflet"; import { type CellHandle, type JSONSchema } from "@commontools/runtime-client"; import { createCellController } from "../../core/cell-controller.ts"; import type { Bounds, CtBoundsChangeDetail, CtCircleClickDetail, CtClickDetail, CtMarkerClickDetail, CtMarkerDragEndDetail, LatLng, MapCircle, MapMarker, MapPolyline, MapValue, } from "./types.ts"; import "../ct-render/ct-render.ts"; // Default map configuration const DEFAULT_CENTER: LatLng = { lat: 37.7749, lng: -122.4194 }; // San Francisco const DEFAULT_ZOOM = 13; const TILE_URL = "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"; const TILE_ATTRIBUTION = '© OpenStreetMap contributors'; // Valid coordinate and zoom ranges const MIN_ZOOM = 0; const MAX_ZOOM = 18; const MIN_LAT = -90; const MAX_LAT = 90; const MIN_LNG = -180; const MAX_LNG = 180; // Keyboard navigation constants const PAN_AMOUNT = 100; // pixels to pan per arrow key press // Cached emoji regex for performance (compiled once at module load) const EMOJI_REGEX = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{1F600}-\u{1F64F}]|[\u{1F680}-\u{1F6FF}]/u; // ResizeObserver debounce delay in milliseconds const RESIZE_DEBOUNCE_MS = 150; // JSON Schemas for nested cell resolution via CellController.bind() // These schemas enable automatic resolution of nested CellHandles const latLngSchema: JSONSchema = { type: "object", properties: { lat: { type: "number" }, lng: { type: "number" }, }, }; const boundsSchema: JSONSchema = { type: "object", properties: { north: { type: "number" }, south: { type: "number" }, east: { type: "number" }, west: { type: "number" }, }, }; const mapValueSchema: JSONSchema = { type: "object", properties: { markers: { type: "array", items: { type: "object", properties: { position: latLngSchema, title: { type: "string" }, description: { type: "string" }, icon: { type: "string" }, draggable: { type: "boolean" }, // popup is OpaqueRef, left unspecified to preserve as-is }, }, }, circles: { type: "array", items: { type: "object", properties: { center: latLngSchema, radius: { type: "number" }, color: { type: "string" }, fillOpacity: { type: "number" }, strokeWidth: { type: "number" }, title: { type: "string" }, description: { type: "string" }, // popup is OpaqueRef, left unspecified to preserve as-is }, }, }, polylines: { type: "array", items: { type: "object", properties: { points: { type: "array", items: latLngSchema, }, color: { type: "string" }, strokeWidth: { type: "number" }, dashArray: { type: "string" }, }, }, }, }, }; /** * CTMap - Interactive map component with markers, circles, and polylines * * @element ct-map * * @attr {MapValue|Cell} value - Map data with markers, circles, polylines * @attr {LatLng|Cell} center - Map center coordinates (bidirectional) * @attr {number|Cell} zoom - Map zoom level (bidirectional) * @attr {Bounds|Cell} bounds - Visible map bounds (bidirectional) * @attr {boolean} fitToBounds - Auto-fit to show all features * @attr {boolean} interactive - Enable pan/zoom (default: true) * * @fires ct-click - Map background click with { lat, lng } * @fires ct-bounds-change - Viewport change with { bounds, center, zoom } * @fires ct-marker-click - Marker click with { marker, index, lat, lng } * @fires ct-marker-drag-end - Marker drag end with { marker, index, position, oldPosition } * @fires ct-circle-click - Circle click with { circle, index, lat, lng } * * @note Polyline click events are not currently supported. Polylines are rendered * for display only (routes, paths). For clickable segments, use circles as waypoints. * * @example * */ export class CTMap extends BaseElement { static override styles = [BaseElement.baseStyles, styles]; static override properties = { value: { attribute: false }, center: { attribute: false }, zoom: { attribute: false }, bounds: { attribute: false }, fitToBounds: { type: Boolean }, interactive: { type: Boolean }, }; declare value: CellHandle | MapValue; declare center: CellHandle | LatLng; declare zoom: CellHandle | number; declare bounds: CellHandle | Bounds; declare fitToBounds: boolean; declare interactive: boolean; // Leaflet map instance private _map: L.Map | null = null; // Layer groups for organized management private _markerLayer: L.LayerGroup | null = null; private _circleLayer: L.LayerGroup | null = null; private _polylineLayer: L.LayerGroup | null = null; // Track markers for drag events private _leafletMarkers: L.Marker[] = []; // Track circles for click events private _leafletCircles: L.Circle[] = []; // Track polylines for cleanup private _leafletPolylines: L.Polyline[] = []; // RAF ID for map initialization (prevent race condition on disconnect) private _rafId: number | null = null; // ResizeObserver for automatic map resize when container changes private _resizeObserver: ResizeObserver | null = null; // Timeout ID for debounced resize handling private _resizeTimeoutId: ReturnType | null = null; // Flag to prevent echo loops during programmatic updates private _isUpdatingFromCell = false; // Flag to track pending click events deferred during animations private _pendingClickEvent: L.LeafletMouseEvent | null = null; // Pending updates deferred during animations // These are processed when the map stabilizes (on zoomend) private _pendingCenterUpdate: LatLng | null = null; private _pendingZoomUpdate: number | null = null; private _pendingBoundsUpdate: Bounds | null = null; private _pendingFitToBounds = false; // Cell controllers for reactive data binding // These manage subscriptions automatically via Lit's ReactiveController lifecycle // Note: Feature rendering is handled in updated() to catch both property changes // and cell subscription updates private _valueController = createCellController(this, { timing: { strategy: "immediate" }, }); private _centerController = createCellController(this, { timing: { strategy: "immediate" }, triggerUpdate: false, onChange: () => { if (!this._isUpdatingFromCell) { this._updateMapCenter(); } }, }); private _zoomController = createCellController(this, { timing: { strategy: "immediate" }, triggerUpdate: false, onChange: () => { if (!this._isUpdatingFromCell) { this._updateMapZoom(); } }, }); private _boundsController = createCellController(this, { timing: { strategy: "immediate" }, triggerUpdate: false, onChange: () => { if (!this._isUpdatingFromCell) { this._updateMapFromBounds(); } }, }); // Bound event handler for cleanup private _boundHandleKeydown = this._handleKeydown.bind(this); constructor() { super(); this.fitToBounds = false; this.interactive = true; } override connectedCallback(): void { super.connectedCallback(); this.addEventListener("keydown", this._boundHandleKeydown); } override disconnectedCallback(): void { super.disconnectedCallback(); this.removeEventListener("keydown", this._boundHandleKeydown); this._cleanup(); } protected override firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties); // Bind all controllers to their initial values with JSON schemas // Schema binding enables automatic resolution of nested CellHandles this._valueController.bind(this.value, mapValueSchema); this._centerController.bind(this.center, latLngSchema); this._zoomController.bind(this.zoom); this._boundsController.bind(this.bounds, boundsSchema); this._initializeMap(); // Render features after map initialization this._renderFeatures(); } protected override updated(changedProperties: PropertyValues): void { super.updated(changedProperties); // Handle value changes - rebind controller when property changes if (changedProperties.has("value")) { this._valueController.bind(this.value, mapValueSchema); } // Re-render features on every update when we have a Cell // Cell subscription updates trigger requestUpdate() but don't show in changedProperties if (this._valueController.hasCell() || changedProperties.has("value")) { this._renderFeatures(); } // Handle center changes - rebind controller when property changes if (changedProperties.has("center")) { this._centerController.bind(this.center, latLngSchema); this._updateMapCenter(); } // Handle zoom changes - rebind controller when property changes if (changedProperties.has("zoom")) { this._zoomController.bind(this.zoom); this._updateMapZoom(); } // Handle bounds changes - rebind controller when property changes if (changedProperties.has("bounds")) { this._boundsController.bind(this.bounds, boundsSchema); } // Handle interactive changes if (changedProperties.has("interactive") && this._map) { if (this.interactive) { this._map.dragging.enable(); this._map.touchZoom.enable(); this._map.doubleClickZoom.enable(); this._map.scrollWheelZoom.enable(); this._map.boxZoom.enable(); this._map.keyboard.enable(); } else { this._map.dragging.disable(); this._map.touchZoom.disable(); this._map.doubleClickZoom.disable(); this._map.scrollWheelZoom.disable(); this._map.boxZoom.disable(); this._map.keyboard.disable(); } } // Handle fitToBounds changes if (changedProperties.has("fitToBounds") && this.fitToBounds) { this._fitMapToBounds(); } } override render() { return html`
`; } // === Keyboard Navigation === private _handleKeydown(event: KeyboardEvent): void { // Only handle when interactive and map exists and is stable if (!this.interactive || !this._map || !this._isMapStable()) return; // Check that the event target is within the map container const mapContainer = this.shadowRoot?.querySelector(".map-container"); if (!mapContainer) return; // Only handle events when focus is on the map container const target = event.target as Node; if (target !== mapContainer && !mapContainer.contains(target)) return; switch (event.key) { case "ArrowUp": event.preventDefault(); this._map.panBy([0, -PAN_AMOUNT]); break; case "ArrowDown": event.preventDefault(); this._map.panBy([0, PAN_AMOUNT]); break; case "ArrowLeft": event.preventDefault(); this._map.panBy([-PAN_AMOUNT, 0]); break; case "ArrowRight": event.preventDefault(); this._map.panBy([PAN_AMOUNT, 0]); break; case "+": case "=": event.preventDefault(); this._map.zoomIn(); break; case "-": event.preventDefault(); this._map.zoomOut(); break; } } // === Map State Helpers === /** * Check if the map is in a stable state for operations. * Returns false during zoom animations or when the map pane is not ready. * This prevents the Leaflet race condition where _mapPane becomes undefined * during concurrent pan+click operations. */ private _isMapStable(): boolean { if (!this._map) return false; // Check if map is during a zoom animation // Access internal Leaflet state - _animatingZoom is set during zoom transitions const map = this._map as L.Map & { _animatingZoom?: boolean }; if (map._animatingZoom) return false; // Check if map pane exists and is ready // This is the element that causes the _leaflet_pos error when undefined try { const pane = this._map.getPane("mapPane"); if (!pane) return false; } catch { return false; } return true; } // === Map Initialization === private _initializeMap(): void { const container = this.shadowRoot?.querySelector( ".map-container", ) as HTMLElement; if (!container) return; // Get initial center and zoom const initialCenter = this._getCenter(); const initialZoom = this._getZoom(); // Create map instance this._map = L.map(container, { center: [initialCenter.lat, initialCenter.lng], zoom: initialZoom, dragging: this.interactive, touchZoom: this.interactive, doubleClickZoom: this.interactive, scrollWheelZoom: this.interactive, boxZoom: this.interactive, keyboard: this.interactive, zoomControl: this.interactive, }); // Add tile layer with crossOrigin for CORS compatibility const tileLayer = L.tileLayer(TILE_URL, { attribution: TILE_ATTRIBUTION, crossOrigin: true, }); // Handle tile loading errors gracefully tileLayer.on("tileerror", (event: L.TileErrorEvent) => { console.warn("ct-map: Tile loading error", { coords: event.coords, error: event.error, }); }); tileLayer.addTo(this._map); // Create layer groups this._markerLayer = L.layerGroup().addTo(this._map); this._circleLayer = L.layerGroup().addTo(this._map); this._polylineLayer = L.layerGroup().addTo(this._map); // Set up event handlers this._setupMapEventHandlers(); // Invalidate size after initialization to ensure proper tile loading // This is necessary when the map is rendered in Shadow DOM this._rafId = requestAnimationFrame(() => { this._map?.invalidateSize(); this._rafId = null; }); // Set up ResizeObserver to handle container size changes // This handles responsive layouts, tab switching, accordion panels, etc. // Debounce to prevent excessive layout thrashing on every pixel change this._resizeObserver = new ResizeObserver(() => { if (this._resizeTimeoutId !== null) { clearTimeout(this._resizeTimeoutId); } this._resizeTimeoutId = setTimeout(() => { this._map?.invalidateSize(); this._resizeTimeoutId = null; }, RESIZE_DEBOUNCE_MS); }); this._resizeObserver.observe(container); } private _setupMapEventHandlers(): void { if (!this._map) return; // Map click event - guard against race condition during zoom animations this._map.on("click", (e: L.LeafletMouseEvent) => { // If map is not stable (e.g., during zoom animation), defer the click if (!this._isMapStable()) { this._pendingClickEvent = e; return; } this._emitClickEvent(e); }); // Handle deferred updates after zoom animations complete this._map.on("zoomend", () => { if (!this._isMapStable()) return; // Process pending click event if (this._pendingClickEvent) { this._emitClickEvent(this._pendingClickEvent); this._pendingClickEvent = null; } // Process pending map updates (queued during animation) // Process in order: bounds first (most specific), then center, then zoom this._processPendingUpdates(); }); // Bounds change event (moveend covers both pan and zoom) this._map.on("moveend", () => { if (this._isUpdatingFromCell) return; // Guard against race condition - map pane may not be ready if (!this._isMapStable()) return; // Wrap in try-catch as additional safety for Leaflet internal state issues try { const bounds = this._map!.getBounds(); const center = this._map!.getCenter(); const zoom = this._map!.getZoom(); const boundsData: Bounds = { north: bounds.getNorth(), south: bounds.getSouth(), east: bounds.getEast(), west: bounds.getWest(), }; const centerData: LatLng = { lat: center.lat, lng: center.lng, }; // Emit bounds change event const detail: CtBoundsChangeDetail = { bounds: boundsData, center: centerData, zoom, }; this.emit("ct-bounds-change", detail); // Update Cell values (bidirectional) using controllers this._updateCenterCell(centerData); this._updateZoomCell(zoom); this._updateBoundsCell(boundsData); } catch { // Silently ignore errors from Leaflet internal state issues // This can happen during rapid pan+click operations } }); } /** * Emit click event with coordinates. * Extracted to allow deferred clicks after animations. */ private _emitClickEvent(e: L.LeafletMouseEvent): void { const detail: CtClickDetail = { lat: e.latlng.lat, lng: e.latlng.lng, }; this.emit("ct-click", detail); } // === Value Getters (using CellControllers) === private _getValue(): MapValue { return this._valueController.getValue() || {}; } private _getCenter(): LatLng { const center = this._centerController.getValue() || DEFAULT_CENTER; return this._validateLatLng(center); } private _getZoom(): number { const zoom = this._zoomController.getValue() ?? DEFAULT_ZOOM; return this._clampZoom(zoom); } private _getBounds(): Bounds | null { return this._boundsController.getValue() || null; } // === Cell Updates (bidirectional, using CellControllers) === private _updateCenterCell(center: LatLng): void { if (!this._centerController.hasCell()) return; this._isUpdatingFromCell = true; try { this._centerController.setValue(center); } finally { this._isUpdatingFromCell = false; } } private _updateZoomCell(zoom: number): void { if (!this._zoomController.hasCell()) return; this._isUpdatingFromCell = true; try { this._zoomController.setValue(zoom); } finally { this._isUpdatingFromCell = false; } } private _updateBoundsCell(bounds: Bounds): void { if (!this._boundsController.hasCell()) return; this._isUpdatingFromCell = true; try { this._boundsController.setValue(bounds); } finally { this._isUpdatingFromCell = false; } } // === Map Updates === private _updateMapCenter(): void { if (!this._map) return; const center = this._getCenter(); // Already validated by _getCenter() // If map is not stable, queue the update for when it stabilizes if (!this._isMapStable()) { this._pendingCenterUpdate = center; return; } this._isUpdatingFromCell = true; try { this._map.setView([center.lat, center.lng], this._map.getZoom(), { animate: true, }); } finally { this._isUpdatingFromCell = false; } } private _updateMapZoom(): void { if (!this._map) return; const zoom = this._getZoom(); // Already validated by _getZoom() // If map is not stable, queue the update for when it stabilizes if (!this._isMapStable()) { this._pendingZoomUpdate = zoom; return; } this._isUpdatingFromCell = true; try { this._map.setZoom(zoom, { animate: true }); } finally { this._isUpdatingFromCell = false; } } private _updateMapFromBounds(): void { if (!this._map) return; const bounds = this._getBounds(); if (!bounds) return; // Validate bounds before applying const validatedBounds = this._validateBounds(bounds); if (!validatedBounds) return; // If map is not stable, queue the update for when it stabilizes if (!this._isMapStable()) { this._pendingBoundsUpdate = validatedBounds; return; } this._isUpdatingFromCell = true; try { const leafletBounds = L.latLngBounds( [validatedBounds.south, validatedBounds.west], [validatedBounds.north, validatedBounds.east], ); this._map.fitBounds(leafletBounds, { animate: true }); } finally { this._isUpdatingFromCell = false; } } /** * Process any pending updates that were queued during animations. * Called from zoomend handler when map becomes stable. */ private _processPendingUpdates(): void { if (!this._map) return; // Process bounds update (most specific - sets both center and zoom) if (this._pendingBoundsUpdate) { const bounds = this._pendingBoundsUpdate; this._pendingBoundsUpdate = null; // Clear other pending updates since bounds encompasses them this._pendingCenterUpdate = null; this._pendingZoomUpdate = null; this._isUpdatingFromCell = true; try { const leafletBounds = L.latLngBounds( [bounds.south, bounds.west], [bounds.north, bounds.east], ); this._map.fitBounds(leafletBounds, { animate: true }); } finally { this._isUpdatingFromCell = false; } return; // Let the next zoomend handle any remaining updates } // Process center update if (this._pendingCenterUpdate) { const center = this._pendingCenterUpdate; this._pendingCenterUpdate = null; this._isUpdatingFromCell = true; try { this._map.setView([center.lat, center.lng], this._map.getZoom(), { animate: true, }); } finally { this._isUpdatingFromCell = false; } return; // Let the next zoomend handle zoom update if any } // Process zoom update if (this._pendingZoomUpdate !== null) { const zoom = this._pendingZoomUpdate; this._pendingZoomUpdate = null; this._isUpdatingFromCell = true; try { this._map.setZoom(zoom, { animate: true }); } finally { this._isUpdatingFromCell = false; } return; } // Process fitToBounds if (this._pendingFitToBounds) { this._pendingFitToBounds = false; this._fitMapToBounds(); } } // === Feature Rendering === private _clearLayers(): void { // Remove event listeners from tracked markers before clearing to prevent memory leaks // Leaflet's clearLayers() removes DOM nodes but doesn't remove .on() listeners for (const marker of this._leafletMarkers) { marker.off(); } for (const circle of this._leafletCircles) { circle.off(); } for (const polyline of this._leafletPolylines) { polyline.off(); } this._markerLayer?.clearLayers(); this._circleLayer?.clearLayers(); this._polylineLayer?.clearLayers(); this._leafletMarkers = []; this._leafletCircles = []; this._leafletPolylines = []; } /** * Render all map features from the current value. * Schema binding via CellController.bind() automatically resolves nested CellHandles, * so we can read directly from _getValue() without manual cell traversal. */ private _renderFeatures(): void { if (!this._map) return; const value = this._getValue(); // Clear existing layers this._clearLayers(); // Render features from resolved data if (value.markers && value.markers.length > 0) { this._renderMarkers(value.markers); } if (value.circles && value.circles.length > 0) { this._renderCircles(value.circles); } if (value.polylines && value.polylines.length > 0) { this._renderPolylines(value.polylines); } // Handle fitToBounds if (this.fitToBounds) { this._fitMapToBounds(); } } private _renderMarkers(markers: readonly MapMarker[]): void { if (!this._markerLayer) return; // Access length inside try-catch since reactive proxies can throw on property access let length: number; try { length = markers.length; } catch { return; } for (let i = 0; i < length; i++) { const index = i; // Extract all marker properties within try-catch to handle reactive proxy edge cases // where accessing nested properties may throw during partial data loading let marker: MapMarker; let lat: number; let lng: number; let title: string | undefined; let description: string | undefined; let icon: string | undefined; let popup: unknown; let draggable: boolean | undefined; try { marker = markers[i]; if (!marker) { continue; } // Marker is resolved by the effect system const position = marker.position; if (!position) { continue; } lat = position.lat; lng = position.lng; if (typeof lat !== "number" || typeof lng !== "number") { continue; } // Extract remaining properties title = marker.title; description = marker.description; icon = marker.icon; popup = marker.popup; draggable = marker.draggable; } catch { // Skip markers that throw during property access continue; } // Create marker icon - always use divIcon to avoid Leaflet default icon issues in Shadow DOM let markerIcon: L.DivIcon; if (icon && this._isEmoji(icon)) { // Create a span element safely to prevent XSS const span = document.createElement("span"); span.className = "emoji-marker"; span.textContent = icon; // Safe - escapes HTML markerIcon = L.divIcon({ html: span.outerHTML, className: "ct-map-emoji-marker", iconSize: [32, 32], iconAnchor: [16, 32], popupAnchor: [0, -32], }); } else { // Default marker icon - SVG pin that works in Shadow DOM // (Leaflet's default L.Icon.Default fails in Shadow DOM due to image path resolution) markerIcon = L.divIcon({ html: ` `, className: "ct-map-default-marker", iconSize: [25, 41], iconAnchor: [12, 41], popupAnchor: [0, -41], }); } // Create Leaflet marker (use validated lat/lng from above) const leafletMarker = L.marker([lat, lng], { icon: markerIcon, draggable: draggable || false, }); // Add popup if content is provided if (popup || title || description) { const popupContent = this._createPopupContent(marker, "marker", index); leafletMarker.bindPopup(popupContent); } // Click handler leafletMarker.on("click", (e: L.LeafletMouseEvent) => { const detail: CtMarkerClickDetail = { marker, index, lat: e.latlng.lat, lng: e.latlng.lng, }; this.emit("ct-marker-click", detail); }); // Drag handlers if (draggable) { // Use validated lat/lng instead of spreading reactive proxy let oldPosition: LatLng = { lat, lng }; // Capture position at drag start (not creation time) leafletMarker.on("dragstart", () => { const pos = leafletMarker.getLatLng(); oldPosition = { lat: pos.lat, lng: pos.lng }; }); leafletMarker.on("dragend", (e: L.DragEndEvent) => { const newLatLng = e.target.getLatLng(); const newPosition: LatLng = { lat: newLatLng.lat, lng: newLatLng.lng, }; const detail: CtMarkerDragEndDetail = { marker, index, position: newPosition, oldPosition, }; this.emit("ct-marker-drag-end", detail); }); } // Add to layer and track leafletMarker.addTo(this._markerLayer!); this._leafletMarkers.push(leafletMarker); } } private _renderCircles(circles: readonly MapCircle[]): void { if (!this._circleLayer) return; // Access length inside try-catch since reactive proxies can throw on property access let length: number; try { length = circles.length; } catch { return; } for (let i = 0; i < length; i++) { const index = i; // Extract all circle properties within try-catch let circle: MapCircle; let lat: number; let lng: number; let radius: number; let color: string | undefined; let fillOpacity: number | undefined; let strokeWidth: number | undefined; let popup: unknown; let title: string | undefined; try { circle = circles[i]; if (!circle) { continue; } // Circle is resolved by the effect system const center = circle.center; if (!center) { continue; } lat = center.lat; lng = center.lng; if (typeof lat !== "number" || typeof lng !== "number") { continue; } // Extract remaining properties radius = circle.radius; color = circle.color; fillOpacity = circle.fillOpacity; strokeWidth = circle.strokeWidth; popup = circle.popup; title = circle.title; } catch { // Skip circles that throw during property access continue; } // Create Leaflet circle (use validated lat/lng from above) const leafletCircle = L.circle([lat, lng], { radius, color: color || "#3b82f6", fillColor: color || "#3b82f6", fillOpacity: fillOpacity ?? 0.2, weight: strokeWidth ?? 2, }); // Add popup if content is provided if (popup || title) { const popupContent = this._createPopupContent(circle, "circle", index); leafletCircle.bindPopup(popupContent); } // Click handler leafletCircle.on("click", (e: L.LeafletMouseEvent) => { const detail: CtCircleClickDetail = { circle, index, lat: e.latlng.lat, lng: e.latlng.lng, }; this.emit("ct-circle-click", detail); }); // Add to layer and track leafletCircle.addTo(this._circleLayer!); this._leafletCircles.push(leafletCircle); } } private _renderPolylines(polylines: readonly MapPolyline[]): void { if (!this._polylineLayer) return; // Access length inside try-catch since reactive proxies can throw on property access let length: number; try { length = polylines.length; } catch { return; } for (let i = 0; i < length; i++) { const polyline = polylines[i]; // Skip polylines without valid points array if (!polyline) { continue; } // Polyline is resolved by the effect system const points = polyline.points; if (!points || !Array.isArray(points)) { continue; } // Extract style properties const color = polyline.color; const strokeWidth = polyline.strokeWidth; const dashArray = polyline.dashArray; // Convert to Leaflet format, filtering out invalid points const latLngs: L.LatLngExpression[] = []; const pointsLength = points.length; for (let j = 0; j < pointsLength; j++) { const p = points[j]; if (p && typeof p.lat === "number" && typeof p.lng === "number") { latLngs.push([p.lat, p.lng]); } } // Skip if no valid points remain if (latLngs.length === 0) { continue; } // Create Leaflet polyline // Note: Polylines are display-only (no click events). Use circles as // waypoints if clickable segments are needed. const leafletPolyline = L.polyline(latLngs, { color: color || "#3b82f6", weight: strokeWidth ?? 3, dashArray: dashArray, }); // Add to layer and track leafletPolyline.addTo(this._polylineLayer!); this._leafletPolylines.push(leafletPolyline); } } // === Popup Rendering === private _createPopupContent( feature: MapMarker | MapCircle, _type: "marker" | "circle", _index: number, ): HTMLElement { const container = document.createElement("div"); // Check if we have an OpaqueRef popup (advanced mode) if (feature.popup) { // Create a ct-render element for the popup content const ctRender = document.createElement("ct-render"); (ctRender as any).cell = feature.popup; container.appendChild(ctRender); } else { // Simple popup content container.className = "popup-simple"; if ("icon" in feature && feature.icon) { const iconEl = document.createElement("div"); iconEl.className = "popup-icon"; iconEl.textContent = feature.icon; container.appendChild(iconEl); } if (feature.title) { const titleEl = document.createElement("div"); titleEl.className = "popup-title"; titleEl.textContent = feature.title; container.appendChild(titleEl); } if (feature.description) { const descEl = document.createElement("p"); descEl.className = "popup-description"; descEl.textContent = feature.description; container.appendChild(descEl); } } return container; } // === Fit to Bounds === private _fitMapToBounds(): void { if (!this._map) return; // If map is not stable, queue the update for when it stabilizes if (!this._isMapStable()) { this._pendingFitToBounds = true; return; } const value = this._getValue(); const allPoints: L.LatLng[] = []; // Collect all marker positions // Use traditional for loops with try-catch to avoid reactive proxy issues if (value.markers) { const markers = value.markers; const markersLength = markers.length; for (let i = 0; i < markersLength; i++) { try { const m = markers[i]; if (m) { const position = m.position; if (position) { const lat = position.lat; const lng = position.lng; if (typeof lat === "number" && typeof lng === "number") { allPoints.push(L.latLng(lat, lng)); } } } } catch { // Skip markers that throw during property access } } } // Collect all circle centers (could also use circle bounds) if (value.circles) { const circles = value.circles; const circlesLength = circles.length; for (let i = 0; i < circlesLength; i++) { try { const c = circles[i]; if (c) { const center = c.center; if (center) { const lat = center.lat; const lng = center.lng; if (typeof lat === "number" && typeof lng === "number") { allPoints.push(L.latLng(lat, lng)); } } } } catch { // Skip circles that throw during property access } } } // Collect all polyline points if (value.polylines) { const polylines = value.polylines; const polylinesLength = polylines.length; for (let i = 0; i < polylinesLength; i++) { try { const p = polylines[i]; if (p) { const points = p.points; if (points) { const pointsLength = points.length; for (let j = 0; j < pointsLength; j++) { try { const pt = points[j]; if (pt) { const lat = pt.lat; const lng = pt.lng; if (typeof lat === "number" && typeof lng === "number") { allPoints.push(L.latLng(lat, lng)); } } } catch { // Skip points that throw during property access } } } } } catch { // Skip polylines that throw during property access } } } // Fit bounds if we have points if (allPoints.length > 0) { const bounds = L.latLngBounds(allPoints); this._isUpdatingFromCell = true; try { this._map.fitBounds(bounds, { padding: [50, 50], maxZoom: 16, }); } finally { this._isUpdatingFromCell = false; } } } // === Utilities === private _isEmoji(str: string): boolean { // Simple emoji detection - checks for emoji unicode ranges // Uses module-level cached regex for performance return EMOJI_REGEX.test(str); } // === Coordinate Validation === /** * Clamp zoom level to valid range, handling NaN/Infinity */ private _clampZoom(zoom: number): number { if (!Number.isFinite(zoom)) { return DEFAULT_ZOOM; } return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); } /** * Clamp latitude to valid range, handling NaN/Infinity */ private _clampLat(lat: number): number { if (!Number.isFinite(lat)) { return DEFAULT_CENTER.lat; } return Math.max(MIN_LAT, Math.min(MAX_LAT, lat)); } /** * Clamp longitude to valid range, handling NaN/Infinity */ private _clampLng(lng: number): number { if (!Number.isFinite(lng)) { return DEFAULT_CENTER.lng; } return Math.max(MIN_LNG, Math.min(MAX_LNG, lng)); } /** * Validate and clamp a LatLng object */ private _validateLatLng(latLng: LatLng): LatLng { return { lat: this._clampLat(latLng.lat), lng: this._clampLng(latLng.lng), }; } /** * Validate bounds data * Returns null if bounds are invalid (non-finite numbers, out of range, or south > north) */ private _validateBounds(bounds: Bounds): Bounds | null { const { north, south, east, west } = bounds; // Check that all values are finite numbers if ( !Number.isFinite(north) || !Number.isFinite(south) || !Number.isFinite(east) || !Number.isFinite(west) ) { return null; } // Check latitude range if ( north < MIN_LAT || north > MAX_LAT || south < MIN_LAT || south > MAX_LAT ) { return null; } // Check longitude range if ( east < MIN_LNG || east > MAX_LNG || west < MIN_LNG || west > MAX_LNG ) { return null; } // Check that south <= north if (south > north) { return null; } return bounds; } // === Cleanup === private _cleanup(): void { // Cancel pending RAF to prevent race condition if component disconnects before it fires if (this._rafId !== null) { cancelAnimationFrame(this._rafId); this._rafId = null; } // Clear pending resize debounce timeout if (this._resizeTimeoutId !== null) { clearTimeout(this._resizeTimeoutId); this._resizeTimeoutId = null; } // Disconnect ResizeObserver if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } // Destroy map if (this._map) { this._map.remove(); this._map = null; } // Clear references this._markerLayer = null; this._circleLayer = null; this._polylineLayer = null; this._leafletMarkers = []; this._leafletCircles = []; this._leafletPolylines = []; this._pendingClickEvent = null; this._pendingCenterUpdate = null; this._pendingZoomUpdate = null; this._pendingBoundsUpdate = null; this._pendingFitToBounds = false; // Note: CellControllers are automatically cleaned up by Lit's reactive // controller system. They implement ReactiveController with // hostDisconnected() callbacks that are automatically invoked when // the host element disconnects. } // === Public API === /** * Get the underlying Leaflet map instance for advanced usage */ get leafletMap(): L.Map | null { return this._map; } /** * Invalidate the map size (call after container resize) */ invalidateSize(): void { this._map?.invalidateSize(); } /** * Programmatically fit the map to show all features */ fitBounds(): void { this._fitMapToBounds(); } /** * Pan to a specific location */ panTo(lat: number, lng: number): void { if (!this._isMapStable()) return; this._map?.panTo([lat, lng]); } /** * Set the map view to a specific location and zoom */ setView(lat: number, lng: number, zoom: number): void { if (!this._isMapStable()) return; this._map?.setView([lat, lng], zoom); } } declare global { interface HTMLElementTagNameMap { "ct-map": CTMap; } }