50.0 KiB · text History 4eee00d
import L from 'leaflet'
// Loaded as text (see --loader:.css=text) so it can be injected into the shadow
// root. The map renders inside a shadow DOM, which stylesheets in the main
// document cannot reach -- without this Leaflet's tiles have no positioning
// rules and lay themselves out as a grid of loose images.
import leafletCss from 'leaflet/dist/leaflet.css'
import { addBasemap, isDarkTheme, onThemeChange } from './basemap'
import { addFullscreenControl } from './fullscreen'

const template = document.createElement('template');
template.innerHTML = `
    <style>
      ${leafletCss}
      :host {
        display: block;
        width: 100%;
        height: 100%;
      }
      .map-container {
        width: 100%;
        height: 100%;
        min-height: 400px;
      }
      /* Only shown once the user has moved the map themselves; until then the
         map follows the data and there is nothing to reset to. */
      .leaflet-reset-view button {
        display: flex;
        align-items: center;
        gap: 4px;
        padding: 3px 7px;
        font: 600 11px/1.4 system-ui, sans-serif;
        color: #334155;
        background: #fff;
        border: none;
        border-radius: 4px;
        cursor: pointer;
      }
      .leaflet-reset-view button:hover {
        background: #f1f5f9;
      }
    </style>
    <div class="map-container">
        <slot></slot>
    </div>
`


// How full a bike is, as depth of green.
//
// Sequential encoding: one hue, monotone lightness, so the reader sees "more"
// without having to learn a key -- and green stays the bikes' own hue, which is
// what tells them from the blue docks and amber queries beside them. Four steps
// rather than a continuous scale: a shade is read by comparison with its
// neighbours, and four are tellable apart where forty are not.
//
// Two ramps because the map has two surfaces. On a light basemap the scale runs
// light-to-dark, empty to full; on a dark one it is anchored the other way, or
// an empty bike would be a black dot on a black map. Each was checked against
// the surface it renders on: every step holds its own against the basemap, and
// the palest end still clears the 2:1 floor.
const CHARGE_RAMP = {
    light: ['#22c55e', '#16a34a', '#15803d', '#14532d'],
    dark: ['#15803d', '#16a34a', '#22c55e', '#4ade80']
}

// A bike whose feed reports neither charge nor range. Neutral rather than a
// step of the ramp: any green here is a claim about how full it is, and the
// second step in particular would make an unknown bike indistinguishable from
// a half-charged one.
const NO_CHARGE_COLOR = '#898781'

// The colour is never the only place the number lives -- the popup states the
// charge and the range in words, and says neither for a bike that reports
// neither.
function chargeColor(charge, dark) {
    const value = parseFloat(charge)
    if (!isFinite(value)) return NO_CHARGE_COLOR

    const ramp = dark ? CHARGE_RAMP.dark : CHARGE_RAMP.light
    const step = Math.min(ramp.length - 1, Math.max(0, Math.floor((value / 100) * ramp.length)))
    return ramp[step]
}

// The glyphs the preview cards label their numbers with, so a popup reads the
// way the card for the same query reads.
//
// Codepoints rather than Font Awesome's own classes: the map draws inside a
// shadow root, which the document's stylesheet cannot reach, and inlining
// 138KB of Font Awesome CSS to reach a dozen icons is a poor trade. The
// @font-face is document-level and font faces are not scoped to a tree, so the
// family is available in here -- it is only the class rules that are not.
// Taken from @fortawesome/fontawesome-free 6, solid.
const ICON_GLYPHS = {
    'bicycle': '\uf206',
    'bolt-lightning': '\ue0b7',
    'square-parking': '\uf540',
    'battery-empty': '\uf244',
    'battery-quarter': '\uf243',
    'battery-half': '\uf242',
    'battery-three-quarters': '\uf241',
    'battery-full': '\uf240',
    'road': '\uf018',
    'clock': '\uf017',
    'tower-broadcast': '\uf519',
    'lungs': '\uf604',
    'ban': '\uf05e',
    'lock': '\uf023',
    'train-tram': '\uf7da',
    'train-subway': '\uf239',
    'train': '\uf238',
    'bus': '\uf207',
    'bus-simple': '\uf55e',
    'ferry': '\ue4ea',
    'mountain': '\uf6fc',
    'plane-up': '\ue22d',
    'location-dot': '\uf3c5'
}

// The row's name, drawn as its glyph where there is one and as words where
// there is not -- a route ("71 to Watertown") or a reading ("PM2.5") is the
// data itself, not a label to be replaced by a picture.
function rowLabel(line) {
    const glyph = ICON_GLYPHS[String(line.icon || '').replace(/^fa-/, '')]

    if (!glyph) return `<span class="text-gray-500">${escapeHtml(line.label)}</span>`

    // The name still reaches a screen reader, and a hover still says it.
    return `<span style="font-family:'Font Awesome 6 Free';font-weight:900;font-style:normal"
                  class="text-gray-500" title="${escapeHtml(line.label)}"
                  role="img" aria-label="${escapeHtml(line.label)}">${glyph}</span>`
}

// A glyph on the value rather than in place of the row's name: an arrival time
// is a live one or a scheduled one, which is about the number, not about which
// row it is on.
function valueIcon(line) {
    const glyph = ICON_GLYPHS[String(line.value_icon || '').replace(/^fa-/, '')]
    if (!glyph) return ''

    return `<span style="font-family:'Font Awesome 6 Free';font-weight:900;font-style:normal"
                  class="text-gray-500 mr-1" aria-hidden="true">${glyph}</span>`
}

// Popup rows carry feed text -- a station name, a headsign -- straight from
// the source, so it is escaped rather than interpolated.
function escapeHtml(value) {
    if (value === null || value === undefined) return ''
    return String(value)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
}

// Leaflet draws circles on canvas and nothing else, but a map holding several
// offerings needs a third axis after fill (what kind of thing) and outline
// (which tint) -- so: shape says whose.
//
// This reaches into the Canvas renderer's internals (_drawing, _ctx,
// _fillStroke) because that is the only way to add a path type; they have been
// stable across Leaflet 1.x, and a break shows up as markers that do not draw
// rather than as a crash.
const SHAPE_POINTS = {
    square:   (x, y, r) => [[x - r, y - r], [x + r, y - r], [x + r, y + r], [x - r, y + r]],
    diamond:  (x, y, r) => [[x, y - r], [x + r, y], [x, y + r], [x - r, y]],
    triangle: (x, y, r) => [[x, y - r], [x + r, y + r * 0.8], [x - r, y + r * 0.8]],
    hexagon:  (x, y, r) => {
        const pts = [];
        for (let i = 0; i < 6; i++) {
            const a = Math.PI / 6 + (i * Math.PI) / 3;
            pts.push([x + r * Math.cos(a), y + r * Math.sin(a)]);
        }
        return pts;
    }
};

L.Canvas.include({
    _updateShapeMarker(layer) {
        if (!this._drawing || layer._empty()) return;

        const points = SHAPE_POINTS[layer.options.shape];
        if (!points) return this._updateCircle(layer);

        const p = layer._point;
        const r = Math.max(Math.round(layer._radius), 1);
        const ctx = this._ctx;
        const pts = points(p.x, p.y, r);

        ctx.beginPath();
        ctx.moveTo(pts[0][0], pts[0][1]);
        for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]);
        ctx.closePath();
        this._fillStroke(ctx, layer);
    }
});

// Same geometry and hit-testing as a circleMarker -- only the drawing differs --
// so everything else about a marker keeps working.
const ShapeMarker = L.CircleMarker.extend({
    options: { shape: 'circle' },

    _updatePath() {
        if (!this.options.shape || this.options.shape === 'circle' || !SHAPE_POINTS[this.options.shape]) {
            return this._renderer._updateCircle(this);
        }
        this._renderer._updateShapeMarker(this);
    }
});

class LeafletMap extends HTMLElement {
    constructor() {
        super();

        this.attachShadow({ mode: 'open' });
        this.shadowRoot.appendChild(template.content.cloneNode(true));
        this.mapElement = this.shadowRoot.querySelector('.map-container');
        
        // High-performance markers tracking
        this.markers = new Map(); // Track all markers by ID
        this.markerElements = new Map(); // Track marker DOM elements
        // Which layer group each marker went into. Removing a marker from the
        // map alone leaves the group still holding it, so the group grows for
        // the life of the page and a re-added group brings ghosts back.
        this.markerLayers = new Map();
        this.lines = new Map();
        this.canvasRenderer = null;

        // The map follows the computed view until the user moves it, then
        // stays put and offers to start following again.
        this.followView = true;
        this.programmaticView = false;
        
        // Initialize after DOM is ready
        setTimeout(() => this.initializeMap(), 100);
    }

    static get observedAttributes() { 
        return ['lat', 'lng', 'zoom', 'use-streaming']; 
    }

    attributeChangedCallback(name, oldValue, newValue) {
        if (!this.map) return;

        if (name === 'lat' || name === 'lng' || name === 'zoom') {
            this.applyView();
        }
    }

    // The view attributes are recomputed from the data on every render, so
    // honouring them unconditionally yanked the map back to the centroid of a
    // moving fleet several times a minute -- which is what a redraw looked
    // like even when no marker was touched.
    applyView(force = false) {
        if (!this.map || (!force && !this.followView)) return;

        const lat = parseFloat(this.getAttribute('lat'));
        const lng = parseFloat(this.getAttribute('lng'));
        const zoom = parseInt(this.getAttribute('zoom'));

        if (isNaN(lat) || isNaN(lng)) return;

        const target = L.latLng(lat, lng);
        const nextZoom = isNaN(zoom) ? this.map.getZoom() : zoom;

        if (!force && this.map.getZoom() === nextZoom && !this.viewDriftedFrom(target)) return;

        this.programmaticView = true;
        try {
            this.map.setView(target, nextZoom, { animate: true });
        } finally {
            this.programmaticView = false;
        }
    }

    // A centroid never sits still while vehicles move, so following it exactly
    // would leave the map permanently drifting. Only move once the target has
    // left the middle half of the viewport.
    viewDriftedFrom(target) {
        const bounds = this.map.getBounds();
        const centre = this.map.getCenter();
        const latSlack = (bounds.getNorth() - bounds.getSouth()) / 4;
        const lngSlack = (bounds.getEast() - bounds.getWest()) / 4;

        return Math.abs(target.lat - centre.lat) > latSlack ||
               Math.abs(target.lng - centre.lng) > lngSlack;
    }

    // Both fire synchronously from inside setView, so the flag set around it is
    // enough to tell our own moves from the user's. movestart rather than
    // dragstart because keyboard panning never drags.
    watchForUserView() {
        const stopFollowing = () => {
            if (this.programmaticView || !this.followView) return;
            this.followView = false;
            this.updateResetControl();
        };

        this.map.on('movestart', stopFollowing);
        this.map.on('zoomstart', stopFollowing);
    }

    addResetViewControl() {
        const control = L.control({ position: 'bottomleft' });

        control.onAdd = () => {
            const wrap = L.DomUtil.create('div', 'leaflet-bar leaflet-reset-view');
            wrap.innerHTML = `
                <button type="button" title="Recentre, and follow the data again">
                  <svg viewBox="0 0 24 24" width="11" height="11" aria-hidden="true">
                    <circle cx="12" cy="12" r="6.5" fill="none" stroke="currentColor" stroke-width="2.2" />
                    <path d="M12 1.5v3.5M12 19v3.5M1.5 12h3.5M19 12h3.5"
                          stroke="currentColor" stroke-width="2.2" stroke-linecap="round" />
                  </svg>
                  <span>Reset view</span>
                </button>
            `;

            L.DomEvent.disableClickPropagation(wrap);
            wrap.querySelector('button').addEventListener('click', () => {
                this.followView = true;
                this.applyView(true);
                this.updateResetControl();
            });

            return wrap;
        };

        control.addTo(this.map);
        this.resetViewControl = control;
        this.updateResetControl();
    }

    updateResetControl() {
        const el = this.resetViewControl && this.resetViewControl.getContainer();
        if (el) el.style.display = this.followView ? 'none' : '';
    }

    initializeMap() {
        if (this.map) return; // Already initialized

        const lat = parseFloat(this.getAttribute('lat')) || 39.8283;
        const lng = parseFloat(this.getAttribute('lng')) || -98.5795;
        const zoom = parseInt(this.getAttribute('zoom')) || 4;
        
        // Initialize canvas renderer for high performance
        this.canvasRenderer = L.canvas({ padding: 0.5 });

        this.map = L.map(this.mapElement).setView([lat, lng], zoom);
        
        // Resolve theme variables against the host rather than <html>: custom
        // properties inherit through the shadow boundary, so this picks up any
        // data-theme set closer to the element.
        this.basemap = addBasemap(this.map, {
            styleFrom: this,
            variant: this.getAttribute('basemap') || 'auto',
            tint: this.getAttribute('tint') || undefined,
            strength: this.getAttribute('tint-strength') || undefined,
            // labels="false" trades readable type for one tile request per
            // tile instead of two.
            labels: this.getAttribute('labels') !== 'false'
        });

        // Route lines sit above the theme wash but below the basemap labels and
        // every marker pane, so they read as part of the map rather than as
        // something drawn over it.
        const linePane = this.map.createPane('routeLines');
        linePane.style.zIndex = '260';
        linePane.style.pointerEvents = 'none';
        this.lineRenderer = L.canvas({ pane: 'routeLines', padding: 0.5 });
        this.linesLayer = L.layerGroup().addTo(this.map);

        // Initialize layer groups for different marker types
        this.queriesLayer = L.layerGroup().addTo(this.map);
        this.vehiclesLayer = L.layerGroup().addTo(this.map);
        this.freeBikesLayer = L.layerGroup().addTo(this.map);
        this.aircraftLayer = L.layerGroup().addTo(this.map);
        this.stationsLayer = L.layerGroup().addTo(this.map);

        // Set up streaming event handlers if enabled
        if (this.getAttribute('use-streaming') === 'true') {
            this.setupStreamingHandlers();
        }

        // Leaflet measures its container once, at init. Inside a flex column
        // that width is not final yet, so without this the panes keep the
        // stale size and spill outside the element's box.
        this.resizeObserver = new ResizeObserver(() => {
            if (this.map) this.map.invalidateSize({ animate: false });
        });
        this.resizeObserver.observe(this.mapElement);

        // The charge ramp is anchored to the surface it is drawn on, so a theme
        // flip restyles the bikes rather than leaving a scale reading against
        // its own background.
        this.unwatchTheme = onThemeChange(() => {
            this.repaintChargeMarkers();
            // A line coloured by a theme variable has no attribute change to
            // react to when the theme flips -- only what the name resolves to
            // moved. One subscription for both, since it is one signal and
            // disconnectedCallback already tears this one down.
            this.querySelectorAll('leaflet-line').forEach((lineEl) => this.addLineElement(lineEl));
        });

        // After the initial setView, so it is not mistaken for a user move.
        this.watchForUserView();
        this.addResetViewControl();

        // The host, not the map container: the container lives in the shadow
        // tree, and fullscreening it would take the map away from the element
        // whose height it is sized against.
        this.fullscreen = addFullscreenControl(this.map, { target: this });

        // One more pass after layout settles, for the first paint.
        requestAnimationFrame(() => this.map && this.map.invalidateSize({ animate: false }));

        console.log('Leaflet map initialized with Web Components');
    }

    connectedCallback() {
        // Observer for marker elements being added/removed
        // Reordering a child is one removal and one insertion of the *same*
        // node, and the two records arrive in whichever order the patch made
        // them -- so acting on each record in turn either rebuilt the marker
        // for nothing or, when the addition came first, deleted it outright.
        // Collect the nodes the batch touched and ask the DOM what became of
        // each one instead.
        this.observer = new MutationObserver((mutations) => {
            const touched = new Set();

            mutations.forEach((mutation) => {
                mutation.addedNodes.forEach(node => this.collectTouched(node, touched));
                mutation.removedNodes.forEach(node => this.collectTouched(node, touched));
            });

            touched.forEach(node => this.reconcileNode(node));
        });

        this.observer.observe(this, { childList: true, subtree: true });

        // leaflet-marker has always announced attribute changes, but nothing
        // listened, and the MutationObserver above only watches childList. A
        // marker that moved therefore kept the position it was created at for
        // the life of the element -- which is every GTFS vehicle and every
        // free bike, since LiveView patches their lat/lng in place and their
        // ids are stable.
        this.pendingMarkerUpdates = new Map();
        this.addEventListener('marker-updated', (event) => {
            const markerEl = event.target;
            if (!markerEl || markerEl.tagName !== 'LEAFLET-MARKER') return;

            const changed = this.pendingMarkerUpdates.get(markerEl) || new Set();
            changed.add(event.detail && event.detail.attribute);
            this.pendingMarkerUpdates.set(markerEl, changed);

            // lat and lng arrive as two separate events; coalesce so a move is
            // one update rather than two. A microtask rather than a frame:
            // LiveView patches every attribute in a single task, so this still
            // batches the whole update, and unlike requestAnimationFrame it is
            // not suspended while the tab is in the background.
            if (this.markerUpdateQueued) return;
            this.markerUpdateQueued = true;
            queueMicrotask(() => {
                this.markerUpdateQueued = false;
                const batch = this.pendingMarkerUpdates;
                this.pendingMarkerUpdates = new Map();
                batch.forEach((attrs, el) => this.applyMarkerUpdate(el, attrs));
            });
        });

        this.addEventListener('line-updated', (event) => {
            const lineEl = event.target;
            if (lineEl && lineEl.tagName === 'LEAFLET-LINE') this.addLineElement(lineEl);
        });

        // Process existing markers and lines
        setTimeout(() => {
            this.querySelectorAll('leaflet-marker').forEach(markerEl => {
                this.addMarkerElement(markerEl);
            });
            this.querySelectorAll('leaflet-line').forEach(lineEl => {
                this.addLineElement(lineEl);
            });
        }, 200);
    }

    collectTouched(node, touched) {
        if (node.nodeType !== Node.ELEMENT_NODE) return;
        if (node.tagName === 'LEAFLET-MARKER' || node.tagName === 'LEAFLET-LINE') touched.add(node);
    }

    reconcileNode(node) {
        const isMarker = node.tagName === 'LEAFLET-MARKER';

        if (!this.contains(node)) {
            isMarker ? this.removeMarkerElement(node) : this.removeLineElement(node);
            return;
        }

        // Still ours: only a node we are not already drawing needs building. A
        // node that merely moved is identical to the one we hold, and an id
        // reused by a different element does need rebuilding.
        if (isMarker) {
            if (this.markerElements.get(this.markerIdFor(node)) !== node) this.addMarkerElement(node);
        } else if (this.lines.get(node.getAttribute('id')) === undefined) {
            this.addLineElement(node);
        }
    }

    disconnectedCallback() {
        if (this.observer) {
            this.observer.disconnect();
        }
        if (this.resizeObserver) {
            this.resizeObserver.disconnect();
        }
        // Before map.remove(), and unconditionally: the tint registers a
        // theme-change callback module-side, which would otherwise outlive
        // every map torn down by a LiveView navigation.
        if (this.basemap) {
            this.basemap.remove();
            this.basemap = null;
        }
        if (this.unwatchTheme) {
            this.unwatchTheme();
            this.unwatchTheme = null;
        }
        // Likewise: it listens on the document, not on the map.
        if (this.fullscreen) {
            this.fullscreen.remove();
            this.fullscreen = null;
        }
        if (this.map) {
            this.map.remove();
        }
    }

    addLineElement(lineEl) {
        if (!this.map) {
            setTimeout(() => this.addLineElement(lineEl), 100);
            return;
        }

        const lineId = lineEl.getAttribute('id');
        const points = lineEl.getPoints ? lineEl.getPoints() : [];

        this.removeLineById(lineId);
        if (points.length < 2) return;

        const style = lineEl.getStyle(this.map.getContainer());
        const polyline = L.polyline(points, {
            renderer: this.lineRenderer,
            pane: 'routeLines',
            interactive: false,
            ...style
        });

        this.linesLayer.addLayer(polyline);
        this.lines.set(lineId, polyline);
    }

    removeLineElement(lineEl) {
        this.removeLineById(lineEl.getAttribute('id'));
    }

    removeLineById(lineId) {
        const existing = this.lines.get(lineId);
        if (!existing) return;
        this.linesLayer.removeLayer(existing);
        this.lines.delete(lineId);
    }

    // What the marker is drawn from. Everything else -- the name, the route,
    // the vehicle id -- is only ever read back out of the element when a popup
    // opens, so changing it needs no new layer.
    static get ICON_ATTRIBUTES() {
        return new Set(['type', 'tint', 'shape', 'route-type', 'route-color', 'route-text-color', 'aircraft-class']);
    }

    markerIdFor(markerEl) {
        return markerEl.getAttribute('id') ||
            `${parseFloat(markerEl.getAttribute('lat'))}-${parseFloat(markerEl.getAttribute('lng'))}`;
    }

    // Sliding the existing layer is far cheaper than rebuilding it and leaves
    // an open popup open -- worth having when a whole fleet shifts every
    // refresh. This used to require that *only* lat and lng changed, which no
    // moving marker satisfies: vehicles and aircraft report a new bearing with
    // every position, so each one was torn down and rebuilt on every tick.
    applyMarkerUpdate(markerEl, changedAttributes) {
        if (!this.map || !markerEl.isConnected) return;

        const existing = this.markers.get(this.markerIdFor(markerEl));
        if (!existing || !existing.setLatLng) return this.addMarkerElement(markerEl);

        for (const name of changedAttributes) {
            if (LeafletMap.ICON_ATTRIBUTES.has(name)) return this.addMarkerElement(markerEl);
        }

        if (changedAttributes.has('lat') || changedAttributes.has('lng')) {
            const lat = parseFloat(markerEl.getAttribute('lat'));
            const lng = parseFloat(markerEl.getAttribute('lng'));
            if (isNaN(lat) || isNaN(lng)) return this.addMarkerElement(markerEl);
            existing.setLatLng([lat, lng]);
        }

        if (changedAttributes.has('bearing') && !this.rotateMarker(existing, markerEl)) {
            return this.addMarkerElement(markerEl);
        }

        // A bike that has been ridden reports less charge; the shade is a style
        // on the same canvas circle, so it does not need a new layer.
        if (changedAttributes.has('charge') && existing.setStyle) {
            existing.setStyle({
                fillColor: chargeColor(markerEl.getAttribute('charge'), isDarkTheme(this))
            });
        }

        // The popup builds its content lazily, so a stale one only exists while
        // it is on screen.
        if (existing.isPopupOpen && existing.isPopupOpen()) {
            existing.setPopupContent(this.createPopupContent(markerEl));
        }
    }

    repaintChargeMarkers() {
        if (!this.map) return;
        const dark = isDarkTheme(this);

        this.markerElements.forEach((markerEl, id) => {
            const type = markerEl.getAttribute('type');
            if (type !== 'free_bike' && type !== 'free-bike') return;
            const marker = this.markers.get(id);
            if (marker && marker.setStyle) {
                marker.setStyle({ fillColor: chargeColor(markerEl.getAttribute('charge'), dark) });
            }
        });
    }

    // Turning the part of the icon that points somewhere, rather than building
    // a whole new icon for a heading change. False means this marker cannot be
    // turned in place -- an image icon, or a heading appearing or disappearing,
    // which swaps the symbol itself -- and the caller rebuilds it.
    rotateMarker(marker, markerEl) {
        const element = marker.getElement && marker.getElement();
        const rotating = element && element.querySelector('[data-rotates]');
        if (!rotating) return false;

        const bearing = parseFloat(markerEl.getAttribute('bearing'));
        if (isNaN(bearing)) return false;

        rotating.setAttribute('transform', `rotate(${bearing} 12 12)`);
        return true;
    }

    addMarkerElement(markerEl) {
        if (!this.map) {
            // Map not ready yet, try again later
            setTimeout(() => this.addMarkerElement(markerEl), 100);
            return;
        }

        const lat = parseFloat(markerEl.getAttribute('lat'));
        const lng = parseFloat(markerEl.getAttribute('lng'));
        const markerId = this.markerIdFor(markerEl);
        const markerType = markerEl.getAttribute('type') || 'query';

        if (isNaN(lat) || isNaN(lng)) {
            console.warn('Invalid lat/lng for marker:', markerEl);
            return;
        }

        // Remove existing marker if it exists
        this.removeMarkerById(markerId);

        // Determine which layer to use and create appropriate marker
        let leafletMarker;
        let layer = this.queriesLayer; // default

        switch (markerType) {
            case 'vehicle':
                leafletMarker = this.createVehicleMarker(lat, lng, markerEl);
                layer = this.vehiclesLayer;
                break;
            case 'free-bike':
            case 'free_bike':
                leafletMarker = this.createFreeBikeMarker(lat, lng, markerEl);
                layer = this.freeBikesLayer;
                break;
            case 'aircraft':
                leafletMarker = this.createAircraftMarker(lat, lng, markerEl);
                layer = this.aircraftLayer;
                break;

            case 'station':
                leafletMarker = this.createStationMarker(lat, lng, markerEl);
                layer = this.stationsLayer;
                break;
            case 'query':
            default:
                // Use high-performance canvas markers for queries (can be many)
                leafletMarker = this.createOptimizedQueryMarker(lat, lng, markerEl);
                layer = this.queriesLayer;
                break;
        }

        if (leafletMarker) {
            layer.addLayer(leafletMarker);
            this.markers.set(markerId, leafletMarker);
            this.markerElements.set(markerId, markerEl);
            this.markerLayers.set(markerId, layer);

            // Set up click forwarding from Leaflet marker to DOM element
            leafletMarker.on('click', () => {
                markerEl.click();
            });

            // The popup's DOM only exists once opened, so bind the action then.
            leafletMarker.on('popupopen', (e) => {
                const btn = e.popup.getElement()?.querySelector('[data-add-query-btn]');
                if (!btn || btn.dataset.bound) return;
                btn.dataset.bound = '1';
                btn.addEventListener('click', (ev) => {
                    ev.stopPropagation();
                    // Target the hidden child, not the marker itself -- the map
                    // forwards marker clicks, which would fire this on any click.
                    const target = markerEl.querySelector('[data-add-query-target]');
                    if (target) target.click();
                    leafletMarker.closePopup();
                });
            });

            // Watch for icon updates
            const iconEl = markerEl.querySelector('leaflet-icon');
            if (iconEl) {
                iconEl.addEventListener('url-updated', (e) => {
                    this.updateMarkerIcon(leafletMarker, iconEl, markerEl);
                });
            }
        }
    }

    removeMarkerElement(markerEl) {
        this.removeMarkerById(this.markerIdFor(markerEl));
    }

    removeMarkerById(markerId) {
        const existingMarker = this.markers.get(markerId);
        if (!existingMarker) return;

        // Through the group that holds it: taking it off the map alone leaves
        // the group's own reference behind, so the group grows without bound
        // and clearing it later resurrects markers that are long gone.
        const layer = this.markerLayers.get(markerId);
        layer ? layer.removeLayer(existingMarker) : this.map.removeLayer(existingMarker);

        this.markers.delete(markerId);
        this.markerElements.delete(markerId);
        this.markerLayers.delete(markerId);
    }

    createOptimizedQueryMarker(lat, lng, markerEl) {
        // A star, not a circle: unmistakable against the docks and bikes, and
        // there are few enough queries that a DOM icon costs nothing.
        return L.marker([lat, lng], {
            icon: this.createStarIcon(this.getMarkerColor(markerEl), this.getStrokeColor(markerEl), 22),
            keyboard: false,
        }).bindPopup(() => this.createPopupContent(markerEl));
    }

    createVehicleMarker(lat, lng, markerEl) {
        // An explicit <leaflet-icon icon-url> still wins, but the default is
        // drawn rather than fetched. It used to fall back to
        // /images/vehicle-icon.png, which is not in priv/static -- so every
        // transit vehicle rendered a broken image, and since Leaflet's marker
        // alt defaults to "Marker" what you actually saw was clipped alt text
        // where the vehicle should be.
        const iconEl = markerEl.querySelector('leaflet-icon');
        const iconUrl = iconEl?.getAttribute('icon-url');

        if (iconUrl) {
            const iconSize = [
                parseInt(iconEl.getAttribute('width')) || 32,
                parseInt(iconEl.getAttribute('height')) || 32
            ];

            const icon = L.icon({
                iconUrl: iconUrl,
                iconSize: iconSize,
                iconAnchor: [iconSize[0] / 2, iconSize[1] / 2],
                popupAnchor: [0, -iconSize[1] / 2],
                // Empty rather than Leaflet's "Marker" default: if this URL is
                // also missing, show nothing instead of a word on the map.
                alt: ''
            });

            return L.marker([lat, lng], { icon }).bindPopup(() => this.createPopupContent(markerEl));
        }

        const bearing = parseFloat(markerEl.getAttribute('bearing'));

        return L.marker([lat, lng], {
            icon: this.createVehicleIcon(
                this.getMarkerColor(markerEl),
                this.getStrokeColor(markerEl),
                isNaN(bearing) ? null : bearing,
                LeafletMap.VEHICLE_KIND_BY_ROUTE_TYPE[markerEl.getAttribute('route-type')],
                26,
                this.getGlyphColor(markerEl)
            ),
            keyboard: false,
        }).bindPopup(() => this.createPopupContent(markerEl));
    }

    // Glyphs are drawn rather than pulled from Font Awesome: the map lives in
    // a shadow root, and the document's .fa-* rules do not cross that
    // boundary, so an <i class="fa-bus"> renders as nothing in here.
    //
    // GTFS route_type is finer-grained than a 10px glyph can express, so the
    // types are grouped down to shapes that stay legible at marker size.
    static get VEHICLE_GLYPHS() {
        return {
            // rounded body, windscreen band, two wheels
            bus: '<path d="M8 8.5h8v5.2H8z" fill="#fff"/><path d="M8.6 9.4h6.8v1.8H8.6z" fill="currentColor"/>' +
                 '<circle cx="9.6" cy="14.4" r="0.9" fill="#fff"/><circle cx="14.4" cy="14.4" r="0.9" fill="#fff"/>',
            // body with a pantograph stroke on the roof
            tram: '<path d="M8.4 8.6h7.2v5.4H8.4z" fill="#fff"/><path d="M9.1 9.4h5.8v1.7H9.1z" fill="currentColor"/>' +
                  '<path d="M12 6.6v2" stroke="#fff" stroke-width="1" stroke-linecap="round"/>',
            // blunt-nosed carriage
            train: '<path d="M8.4 8.4h7.2v4.4a2.4 2.4 0 0 1-2.4 2.4h-2.4a2.4 2.4 0 0 1-2.4-2.4z" fill="#fff"/>' +
                   '<path d="M9.2 9.2h5.6v2H9.2z" fill="currentColor"/>',
            // hull plus a short mast
            ferry: '<path d="M7.6 12.6h8.8l-1.4 2.6H9z" fill="#fff"/><path d="M11.4 8.2h1.2v4h-1.2z" fill="#fff"/>',
            // cabin hanging from a cable
            gondola: '<path d="M6.6 8h10.8" stroke="#fff" stroke-width="1" stroke-linecap="round"/>' +
                     '<path d="M12 8v1.8" stroke="#fff" stroke-width="1"/>' +
                     '<path d="M9.6 9.8h4.8v4.4H9.6z" fill="#fff"/>'
        };
    }

    static get VEHICLE_KIND_BY_ROUTE_TYPE() {
        return {
            '0': 'tram',    // tram, streetcar, light rail
            '1': 'train',   // subway, metro
            '2': 'train',   // rail
            '3': 'bus',
            '4': 'ferry',
            '5': 'tram',    // cable tram
            '6': 'gondola', // aerial lift
            '7': 'gondola', // funicular
            '11': 'bus',    // trolleybus
            '12': 'train'   // monorail
        };
    }

    // A disc carrying the vehicle kind, with a pointer along the direction of
    // travel. The pointer rotates; the glyph deliberately does not, so it
    // stays readable whichever way the vehicle is heading.
    createVehicleIcon(fill, stroke, bearing, kind, size, glyphColor = '#ffffff') {
        const pointer =
            bearing === null
                ? ''
                : `<polygon data-rotates points="12,0.8 15.2,6.4 8.8,6.4" fill="${fill}" stroke="${stroke}"
                            stroke-width="1.4" stroke-linejoin="round"
                            transform="rotate(${bearing} 12 12)" />`;

        // The glyphs are written against a white mark on a coloured disc, which
        // is the right way round for almost every line colour. An agency that
        // says otherwise -- a pale line wanting a dark mark -- is honoured by
        // recolouring the mark; the parts drawn in currentColor stay the disc's
        // own colour either way.
        const glyph = (LeafletMap.VEHICLE_GLYPHS[kind] || '').replaceAll('#fff', glyphColor);

        return L.divIcon({
            className: 'leaflet-vehicle-icon',
            html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"
                        style="color:${fill}">
                     ${pointer}
                     <circle cx="12" cy="12" r="7.4" fill="${fill}" stroke="${stroke}" stroke-width="1.4" />
                     ${glyph}
                   </svg>`,
            iconSize: [size, size],
            iconAnchor: [size / 2, size / 2],
            popupAnchor: [0, -size / 2],
        });
    }

    createAircraftMarker(lat, lng, markerEl) {
        const track = parseFloat(markerEl.getAttribute('bearing'));

        return L.marker([lat, lng], {
            icon: this.createAircraftIcon(
                this.aircraftColor(markerEl.getAttribute('aircraft-class')),
                this.getStrokeColor(markerEl),
                isNaN(track) ? null : track,
                22
            ),
            keyboard: false,
        }).bindPopup(() => this.createPopupContent(markerEl));
    }

    // Unlike a bus, the whole aircraft turns: a plane symbol that did not point
    // along its track would be misleading, so the silhouette rotates rather
    // than a separate pointer.
    createAircraftIcon(fill, stroke, track, size) {
        const plane =
            '12,1 13.6,8 22,12.5 22,14.5 13.6,12.3 13.2,18.5 16,20.5 16,22 ' +
            '12,20.8 8,22 8,20.5 10.8,18.5 10.4,12.3 2,14.5 2,12.5 10.4,8';

        // No track at all: a disc, so the marker never claims a heading it
        // does not have.
        const body =
            track === null
                ? `<circle cx="12" cy="12" r="5.5" fill="${fill}" stroke="${stroke}" stroke-width="1.4" />`
                : `<polygon data-rotates points="${plane}" fill="${fill}" stroke="${stroke}" stroke-width="1.2"
                            stroke-linejoin="round" transform="rotate(${track} 12 12)" />`;

        return L.divIcon({
            className: 'leaflet-aircraft-icon',
            html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">${body}</svg>`,
            iconSize: [size, size],
            iconAnchor: [size / 2, size / 2],
            popupAnchor: [0, -size / 2],
        });
    }

    aircraftColor(klass) {
        const byClass = {
            military: '#dc2626',   // red
            cargo: '#f97316',      // orange
            commercial: '#38bdf8', // light blue
            general: '#a3a3a3'     // grey
        };

        return byClass[klass] || byClass.general;
    }

    createFreeBikeMarker(lat, lng, markerEl) {
        // Smaller markers for bikes, shaded by how much charge is left in them.
        // A bike is always shaded by charge -- a missing attribute is the
        // unknown case, which chargeColor draws as neutral rather than as a
        // value it does not have.
        const color = markerEl.getAttribute('type') === 'vehicle'
            ? this.getMarkerColor(markerEl)
            : chargeColor(markerEl.getAttribute('charge'), isDarkTheme(this));

        return L.circleMarker([lat, lng], {
            renderer: this.canvasRenderer,
            radius: 4,
            weight: this.hasTint(markerEl) ? 2 : 1,
            color: this.getStrokeColor(markerEl),
            fillColor: color,
            fillOpacity: 0.9,
            fill: true,
        }).bindPopup(() => this.createPopupContent(markerEl));
    }

    createStationMarker(lat, lng, markerEl) {
        const color = this.getMarkerColor(markerEl);

        // Already queried -> star, so it matches the query markers it is one of.
        if (this.markerHasQuery(markerEl)) {
            return L.marker([lat, lng], {
                icon: this.createStarIcon(color, this.getStrokeColor(markerEl), 20),
                keyboard: false,
            }).bindPopup(() => this.createPopupContent(markerEl));
        }

        return new ShapeMarker([lat, lng], {
            renderer: this.canvasRenderer,
            shape: markerEl.getAttribute('shape') || 'circle',
            radius: 7,
            weight: this.hasTint(markerEl) ? 3 : 2,
            color: this.getStrokeColor(markerEl),
            fillColor: color,
            fillOpacity: 0.85,
            fill: true,
        }).bindPopup(() => this.createPopupContent(markerEl));
    }

    updateMarkerIcon(leafletMarker, iconEl, markerEl) {
        const iconUrl = iconEl.getAttribute('icon-url');
        const iconSize = [
            parseInt(iconEl.getAttribute('width')) || 32,
            parseInt(iconEl.getAttribute('height')) || 32
        ];

        // No icon-url means there is no image to switch to; leave whatever the
        // marker already draws rather than replacing it with a broken image.
        if (!iconUrl) return;

        const newIcon = L.icon({
            iconUrl: iconUrl,
            iconSize: iconSize,
            iconAnchor: iconSize,
            // Leaflet defaults this to "Marker", which is what shows when the
            // image fails to load.
            alt: ''
        });

        leafletMarker.setIcon(newIcon);
    }

    // Fill says *what* the marker is; the outline says which tint it belongs to.
    // Previously tint won outright, so a source with a tint painted its queries
    // and its stations the same colour and the type was invisible.
    getMarkerColor(markerEl) {
        // A dock that already has a query reads as a query, not a dock.
        if (this.markerHasQuery(markerEl)) return '#f59e0b'; // amber

        // Indigo vs sky was indistinguishable; these are far apart.
        const byType = {
            'query': '#f59e0b',      // amber
            'station': '#2563eb',    // blue
            'free_bike': '#16a34a',  // green
            'free-bike': '#16a34a',
            'vehicle': '#dc2626',    // red
        };

        // A transit vehicle wears its line's colour where the schedule
        // publishes one: on a source carrying a dozen routes, one red for all
        // of them says only "a vehicle". The red stays as the fallback for a
        // feed that colours nothing.
        const routeColor = markerEl.getAttribute('route-color');
        if (routeColor && markerEl.getAttribute('type') === 'vehicle') return routeColor;

        return byType[markerEl.getAttribute('type') || 'query'] || '#6b7280';
    }

    // What to draw the glyph in. route_text_color exists precisely because a
    // mark has to sit on top of the line colour and stay readable -- white on
    // a yellow bus does not -- so where the agency states one, it wins.
    getGlyphColor(markerEl) {
        if (markerEl.getAttribute('type') !== 'vehicle') return '#ffffff';

        return markerEl.getAttribute('route-text-color') || '#ffffff';
    }

    // Outline: the tint if one is set, otherwise white so the marker still
    // reads against the map.
    getStrokeColor(markerEl) {
        return this.tintColor(markerEl.getAttribute('tint')) || '#ffffff';
    }

    markerHasQuery(markerEl) {
        return markerEl.hasAttribute('data-has-query');
    }

    // Inline SVG rather than a Font Awesome glyph: the map lives in a shadow
    // root, which stylesheets in the main document cannot reach.
    createStarIcon(fill, stroke, size) {
        const points = '12,2 15.09,8.26 22,9.27 17,14.14 18.18,21.02 12,17.77 5.82,21.02 7,14.14 2,9.27 8.91,8.26';

        return L.divIcon({
            className: 'leaflet-star-icon',
            html: `<svg width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                     <polygon points="${points}" fill="${fill}" stroke="${stroke}" stroke-width="1.5"
                              stroke-linejoin="round" />
                   </svg>`,
            iconSize: [size, size],
            iconAnchor: [size / 2, size / 2],
            popupAnchor: [0, -size / 2],
        });
    }

    hasTint(markerEl) {
        return this.tintColor(markerEl.getAttribute('tint')) !== null;
    }

    tintColor(tint) {
        const colorMap = {
            'red': '#ef4444',
            'orange': '#f97316',
            'amber': '#f59e0b',
            'yellow': '#eab308',
            'lime': '#84cc16',
            'green': '#22c55e',
            'emerald': '#10b981',
            'teal': '#14b8a6',
            'cyan': '#06b6d4',
            'sky': '#0ea5e9',
            'blue': '#3b82f6',
            'indigo': '#6366f1',
            'violet': '#8b5cf6',
            'purple': '#a855f7',
            'fuchsia': '#d946ef',
            'pink': '#ec4899',
            'rose': '#f43f5e',
            'stone': '#78716c',
            'slate': '#64748b',
        };

        return colorMap[tint] || null;
    }

    // What a query currently says, as the preview cards say it: the server
    // condenses the same data into label/value lines and hands them over on the
    // marker. Coordinates stay, but at the bottom and in small type -- a popup
    // that leads with them is repeating what the marker's position already
    // said.
    summaryRows(markerEl) {
        const raw = markerEl.getAttribute('summary');
        if (!raw) return '';

        let lines;
        try {
            lines = JSON.parse(raw);
        } catch (e) {
            return '';
        }
        if (!Array.isArray(lines) || lines.length === 0) return '';

        return lines
            .map(
                (line) => `
                <div class="flex justify-between gap-3 text-xs mb-1">
                    ${rowLabel(line)}
                    <span class="font-medium text-gray-800 text-right">${
                        valueIcon(line)
                    }${escapeHtml(line.value)}</span>
                </div>`
            )
            .join('');
    }

    createPopupContent(markerEl) {
        // Extract data from marker element attributes
        const name = markerEl.getAttribute('name') || 'Marker';
        const lat = markerEl.getAttribute('lat');
        const lng = markerEl.getAttribute('lng');
        const type = markerEl.getAttribute('type') || 'query';
        const summary = this.summaryRows(markerEl);

        // Get additional attributes based on type
        let content = `
            <div class="p-2 min-w-48">
                <h3 class="font-semibold text-sm mb-2">${name}</h3>
                ${summary}
        `;

        // Only rows we actually have. This used to print the vehicle-id and
        // route-id attributes, which nothing set, so every transit popup read
        // "Vehicle: null".
        const row = (label, value) =>
            value
                ? `<div class="text-xs text-gray-600 mb-1"><strong>${label}:</strong> ${value}</div>`
                : '';

        if (type === 'vehicle') {
            content +=
                row('Route', markerEl.getAttribute('route')) +
                row('To', markerEl.getAttribute('dest')) +
                row('Direction', markerEl.getAttribute('direction')) +
                row('Mode', markerEl.getAttribute('mode')) +
                row('Vehicle', markerEl.getAttribute('vehicle-id'));
        }

        if (type === 'aircraft') {
            content +=
                row('Class', markerEl.getAttribute('aircraft-class')) +
                row('Heading', markerEl.getAttribute('bearing'));
        }

        // Where it is, after what it says.
        content += `
                <div class="text-[10px] text-gray-400 mt-2">
                    <i class="fa-solid fa-location-dot mr-1"></i>
                    ${parseFloat(lat).toFixed(4)}, ${parseFloat(lng).toFixed(4)}
                </div>
            </div>`;
        // Opt-in: the marker element carries phx-click, so routing the button
        // through markerEl.click() reuses the forwarding that already exists
        // rather than reaching into LiveView from here.
        if (markerEl.hasAttribute('data-add-query')) {
            content += `
                <button type="button" data-add-query-btn
                        class="mt-2 w-full text-xs font-semibold rounded px-2 py-1 bg-indigo-600 text-white hover:bg-indigo-700">
                    + query
                </button>
            `;
        }

        return content;
    }

    // High-performance streaming handlers (adapted from original implementation)
    setupStreamingHandlers() {
        // Listen for custom events from parent LiveView
        this.addEventListener('add-markers-batch', (e) => {
            this.addMarkersBatch(e.detail);
        });

        this.addEventListener('clear-markers', () => {
            this.clearAllMarkers();
        });
    }

    addMarkersBatch(markers) {
        if (!markers || markers.length === 0) return;

        console.log(`Adding ${markers.length} markers in batch via Web Components`);
        
        // Use requestAnimationFrame for non-blocking batch processing
        const addBatch = (startIndex) => {
            const batchSize = 100;
            const endIndex = Math.min(startIndex + batchSize, markers.length);
            
            for (let i = startIndex; i < endIndex; i++) {
                const markerData = markers[i];
                this.createStreamedMarker(markerData);
            }
            
            if (endIndex < markers.length) {
                requestAnimationFrame(() => addBatch(endIndex));
            } else {
                console.log(`Finished adding ${markers.length} streaming markers`);
            }
        };
        
        requestAnimationFrame(() => addBatch(0));
    }

    createStreamedMarker(markerData) {
        // Create and append a marker element for streamed data
        const markerEl = document.createElement('leaflet-marker');
        markerEl.setAttribute('lat', markerData.lat);
        markerEl.setAttribute('lng', markerData.lng);
        markerEl.setAttribute('name', markerData.name || 'Streamed Marker');
        markerEl.setAttribute('type', markerData.type || 'query');
        markerEl.setAttribute('tint', markerData.tint || 'blue');
        markerEl.setAttribute('id', markerData.id || `streamed-${markerData.lat}-${markerData.lng}`);
        
        // Add to DOM - this will trigger the mutation observer
        this.appendChild(markerEl);
    }

    clearAllMarkers() {
        // Remove all marker DOM elements - this will trigger cleanup
        this.querySelectorAll('leaflet-marker').forEach(el => el.remove());
        
        // Clear the maps as well
        this.markers.clear();
        this.markerElements.clear();
        this.markerLayers.clear();
        
        // Clear the layer groups
        if (this.queriesLayer) this.queriesLayer.clearLayers();
        if (this.vehiclesLayer) this.vehiclesLayer.clearLayers();
        if (this.freeBikesLayer) this.freeBikesLayer.clearLayers();
        if (this.stationsLayer) this.stationsLayer.clearLayers();
    }
}

window.customElements.define('leaflet-map', LeafletMap);
export default LeafletMap;