diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue index 479263bce..6e57a7012 100644 --- a/library/components/legends/LegendMobile.vue +++ b/library/components/legends/LegendMobile.vue @@ -23,19 +23,21 @@ useShadowStyles(` mobile chrome (pull tab, collapse/expand caret, touch drag). ── */ /* bottom = the sheet's height (it shows only while the sheet is open, whose height fits its content) + a 16px gap. */ -.floating-mini-image{position:absolute;inset-inline-start:16px;bottom:calc(var(--legend-sheet-h,30%) + 16px);z-index:999;pointer-events:auto;cursor:pointer;} +.floating-mini-image{position:absolute;inset-inline-start:calc(var(--map-fab-beside,0px) + 16px);bottom:calc(var(--legend-sheet-h,30%) + 16px);z-index:999;pointer-events:auto;cursor:pointer;} .mini-image{width:120px;height:150px;object-fit:cover;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.25);display:block;} /* Containment root: clamps absolute children to the map area. */ .legend-sheet-root{position:absolute;inset:0;pointer-events:none;overflow:hidden;} .legend-sheet-root>*{pointer-events:auto;} -/* ★ ON A PHONE THE ENGINE'S CREDIT GOES TO THE TOP, AND THE SHEET SITS AT THE BOTTOM (mobile pass - 2026-09-30). Every bottom placement failed a reader: under the open sheet the credit and the Mapbox - wordmark were covered; lifted above it they floated mid-map; in a strip below it they pushed the legend - off the map's bottom edge. So on a phone both engines' bottom corners move to the TOP corner on the - START side, opposite the map's own control column (left; right in RTL), compact and always visible, - and the sheet sits flush at the map's bottom, beside the round menu button (see the @media block after - the desktop rules). The desktop rules below are unchanged; the notes in them are history. */ +/* ★ THE BOTTOM OF A PHONE MAP (mobile pass 2026-09-30, the bottom-of-map standard). From the bottom up: the + sheet, flush at the map's bottom at FULL width; just above it, the round menu button on the start side + (the layout lifts it from the 'dxmaps:legend-sheet' event in the script) and the engine's credit on the + end side, compact: it shows expanded, then folds to its (i) a few seconds after it appears (script: + CREDIT_FOLD_MS). The sheet takes the full width only while the layout lifts the button above it: the + layout says so with --map-fab-sheet-inset:0px. A layout that does not lift it leaves the sheet beside + the button (--map-fab-beside), so the button never sits on the sheet. This replaces the credit at the top, which a map's own top overlays (the info pointer) + ran over, and the sheet inset beside the menu button, which shifted the legend when the drawer opened. + See the @media block after the desktop rules; the desktop notes below are history. */ /* ── Attribution stays visible (bug 2026-09-16) ── The sheet is a bottom overlay, so the engine's bottom-left logo / bottom-right attribution corners ended up UNDER it in every state (even the 48px collapsed @@ -46,7 +48,6 @@ useShadowStyles(` height transition frame by frame. Desktop: the sheet slot is display:none, the observer sees 0 and never writes the var, so nothing moves. Every profile that renders this sheet loads this stylesheet — no per-profile wiring. */ -.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right, /* One watermark, bottom-right, on every map: the Mapbox wordmark and the attribution "i" in a single tight row, slightly reduced and translucent. The corners sit just above the sheet's collapsed strip (48px), so the watermark is always visible when the legend is closed; an @@ -63,33 +64,41 @@ useShadowStyles(` Both are read from published variables — --legend-sheet-h (this component's own ResizeObserver) and --map-fab-clearance (VerticalTabsLayout, set ONLY while the circle renders) — so a map with no sheet and no fab still moves nothing. The 48px cap on the sheet term is kept as it was. */ -.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right{transform:translateY(calc(-1 * var(--map-fab-clearance,0px)));} +/* Every corner rule here is scoped to .lm-on (see the phone block): this stylesheet outlives the component, + and on Cities, which mounts the sheet only for its card, the credit stayed shrunken and translucent after + the card closed. */ +:host(.lm-on) .mapboxgl-ctrl-bottom-left,:host(.lm-on) .mapboxgl-ctrl-bottom-right,:host(.lm-on) .maplibregl-ctrl-bottom-left,:host(.lm-on) .maplibregl-ctrl-bottom-right,.lm-on .mapboxgl-ctrl-bottom-left,.lm-on .mapboxgl-ctrl-bottom-right,.lm-on .maplibregl-ctrl-bottom-left,.lm-on .maplibregl-ctrl-bottom-right{transform:translateY(calc(-1 * var(--map-fab-clearance,0px)));} /* The scroll-edge gutters (the hoverable side strips, z-index 2) run the full height of the map and would otherwise swallow taps meant for the attribution in the corner: the corner controls sit one tier above them. (Moves to the shared z tiers with the token work.) */ -.mapboxgl-ctrl-bottom-left,.mapboxgl-ctrl-bottom-right,.maplibregl-ctrl-bottom-left,.maplibregl-ctrl-bottom-right{z-index:3;} -.mapboxgl-ctrl-bottom-right,.maplibregl-ctrl-bottom-right{display:flex;flex-direction:row;align-items:center;justify-content:flex-end;gap:2px;opacity:0.72;transform:translateY(calc(-1 * var(--map-fab-clearance,0px))) scale(0.85);transform-origin:bottom right;transition:opacity 0.15s;} -.mapboxgl-ctrl-bottom-right:hover,.maplibregl-ctrl-bottom-right:hover{opacity:1;} -.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,.maplibregl-ctrl-bottom-right .maplibregl-ctrl{margin:0 6px 6px 0;} -.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib.mapboxgl-compact{margin-right:6px;} -/* !important on the move: a plugin may lift its own bottom corners by the round button's height with a - plain rule of the same specificity (a translateY on the corner container), and up here that lift would - push the credit off the top of the map. The corners are not at the bottom on a phone any more. */ +:host(.lm-on) .mapboxgl-ctrl-bottom-left,:host(.lm-on) .mapboxgl-ctrl-bottom-right,:host(.lm-on) .maplibregl-ctrl-bottom-left,:host(.lm-on) .maplibregl-ctrl-bottom-right,.lm-on .mapboxgl-ctrl-bottom-left,.lm-on .mapboxgl-ctrl-bottom-right,.lm-on .maplibregl-ctrl-bottom-left,.lm-on .maplibregl-ctrl-bottom-right{z-index:3;} +:host(.lm-on) .mapboxgl-ctrl-bottom-right,:host(.lm-on) .maplibregl-ctrl-bottom-right,.lm-on .mapboxgl-ctrl-bottom-right,.lm-on .maplibregl-ctrl-bottom-right{display:flex;flex-direction:row;align-items:center;justify-content:flex-end;gap:2px;opacity:0.72;transform:translateY(calc(-1 * var(--map-fab-clearance,0px))) scale(0.85);transform-origin:bottom right;transition:opacity 0.15s;} +:host(.lm-on) .mapboxgl-ctrl-bottom-right:hover,:host(.lm-on) .maplibregl-ctrl-bottom-right:hover,.lm-on .mapboxgl-ctrl-bottom-right:hover,.lm-on .maplibregl-ctrl-bottom-right:hover{opacity:1;} +:host(.lm-on) .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,:host(.lm-on) .maplibregl-ctrl-bottom-right .maplibregl-ctrl,.lm-on .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,.lm-on .maplibregl-ctrl-bottom-right .maplibregl-ctrl{margin:0 6px 6px 0;} +:host(.lm-on) .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib.mapboxgl-compact,.lm-on .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl-attrib.mapboxgl-compact{margin-right:6px;} +/* ON A PHONE (the bottom-of-map standard): both corners sit just above the sheet (--legend-sheet-h, this + component's own observer). The credit's corner goes to the END side, capped short of the menu button on the + start side (--map-fab-beside, set by the layout only while that button shows); the logo corner (Mapbox's + wordmark) sits beside the button on the start side. !important: a plugin may lift its own corners with a + same-specificity translate, and the old desktop lift must not add to the sheet's height. z-index 4 keeps + the (i) tappable above the scroll-edge strips (z 2) that run the map's full height. MapLibre's own logo + control is optional under its licence and goes; the credit (the licence term) stays. */ /* ★ SCOPED TO .lm-on, a class on the HOST while at least one LegendMobile is mounted (script: LM_HOST_CLASS). This CSS is injected once into the host's shadow root and never removed, so a map that mounts the sheet only for a while (Cities with Internet: its phone people-group card) kept its credit at the top after the sheet went, under the map's own legend (measured at 390: 1 of 11 points visible). Scoped, the move ends with the last sheet. ".lm-on X" is the same rule for a page with no shadow root. */ @media (max-width:767.98px){ - :host(.lm-on) .mapboxgl-ctrl-bottom-left,:host(.lm-on) .mapboxgl-ctrl-bottom-right,:host(.lm-on) .maplibregl-ctrl-bottom-left,:host(.lm-on) .maplibregl-ctrl-bottom-right,.lm-on .mapboxgl-ctrl-bottom-left,.lm-on .mapboxgl-ctrl-bottom-right,.lm-on .maplibregl-ctrl-bottom-left,.lm-on .maplibregl-ctrl-bottom-right{top:0 !important;bottom:auto !important;inset-inline-start:0 !important;inset-inline-end:auto !important;transform:none !important;justify-content:flex-start;max-width:calc(100% - 44px);} - /* ONE LINE (mobile pass 2026-09-30, S8). Up here the credit shares the top edge with the map's control - column (the end side, 35px at 390), so it is capped short of it, in type small enough that the whole - licence text fits ONE line in the room left beside the column: measured at 390, the MapLibre credit - is 346px on one line at 10px, 312px at 9px and 294px at 8.5px, against about 302px of room (the box - keeps 36px of padding for its (i) toggle). It still wraps rather than clips, so a longer credit - (another style, another engine) shows whole on two lines instead of losing its terms. */ + :host(.lm-on) .mapboxgl-ctrl-bottom-left,:host(.lm-on) .mapboxgl-ctrl-bottom-right,:host(.lm-on) .maplibregl-ctrl-bottom-left,:host(.lm-on) .maplibregl-ctrl-bottom-right,.lm-on .mapboxgl-ctrl-bottom-left,.lm-on .mapboxgl-ctrl-bottom-right,.lm-on .maplibregl-ctrl-bottom-left,.lm-on .maplibregl-ctrl-bottom-right{top:auto !important;bottom:var(--legend-sheet-h,48px) !important;transform:none !important;opacity:1;z-index:4;} + :host(.lm-on) .mapboxgl-ctrl-bottom-right,:host(.lm-on) .maplibregl-ctrl-bottom-right,.lm-on .mapboxgl-ctrl-bottom-right,.lm-on .maplibregl-ctrl-bottom-right{inset-inline-end:0 !important;inset-inline-start:auto !important;justify-content:flex-end;max-width:calc(100% - var(--map-fab-beside,0px) - 8px);} + :host(.lm-on) .mapboxgl-ctrl-bottom-right,.lm-on .mapboxgl-ctrl-bottom-right{max-width:calc(100% - var(--map-fab-beside,0px) - 104px);} + :host(.lm-on) .mapboxgl-ctrl-bottom-left,:host(.lm-on) .maplibregl-ctrl-bottom-left,.lm-on .mapboxgl-ctrl-bottom-left,.lm-on .maplibregl-ctrl-bottom-left{inset-inline-start:var(--map-fab-beside,0px) !important;inset-inline-end:auto !important;} + :host(.lm-on) .maplibregl-ctrl-logo,.lm-on .maplibregl-ctrl-logo{display:none !important;} + /* Small type (mobile pass 2026-09-30, S8): expanded, the whole licence text takes at most two short lines + in the room beside the menu button, and then folds to its (i). It wraps rather than clips, so a longer + credit (another style, another engine) still shows whole. */ :host(.lm-on) .maplibregl-ctrl-attrib-inner,:host(.lm-on) .mapboxgl-ctrl-attrib-inner,.lm-on .maplibregl-ctrl-attrib-inner,.lm-on .mapboxgl-ctrl-attrib-inner{font-size:8.5px;line-height:12px;} - :host(.lm-on) .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl,:host(.lm-on) .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,:host(.lm-on) .maplibregl-ctrl-bottom-left .maplibregl-ctrl,:host(.lm-on) .maplibregl-ctrl-bottom-right .maplibregl-ctrl,.lm-on .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl,.lm-on .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,.lm-on .maplibregl-ctrl-bottom-left .maplibregl-ctrl,.lm-on .maplibregl-ctrl-bottom-right .maplibregl-ctrl{margin:8px 0 0;margin-inline:8px 0;} + :host(.lm-on) .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl,:host(.lm-on) .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,:host(.lm-on) .maplibregl-ctrl-bottom-left .maplibregl-ctrl,:host(.lm-on) .maplibregl-ctrl-bottom-right .maplibregl-ctrl,.lm-on .mapboxgl-ctrl-bottom-left .mapboxgl-ctrl,.lm-on .mapboxgl-ctrl-bottom-right .mapboxgl-ctrl,.lm-on .maplibregl-ctrl-bottom-left .maplibregl-ctrl,.lm-on .maplibregl-ctrl-bottom-right .maplibregl-ctrl{margin:0 0 8px;margin-inline:8px;} } /* In the sheet the legend's rows fill the full width: the clear "x" column does not reserve space on the right — it floats over the row's right edge only when a row is selected. */ @@ -103,7 +112,7 @@ useShadowStyles(` max-height reads --legend-sheet-top-reserve, set on this element from the TOP_RESERVE_PX constant in the script — the SAME number the touch-drag clamp uses, so CSS and JS can never disagree about how tall the sheet may get. */ -.legend-mobile-sheet{position:absolute;bottom:0;inset-inline-start:var(--map-fab-beside,0px);inset-inline-end:0;z-index:1000;background:white;border-top-left-radius:var(--map-radius,16px);border-top-right-radius:var(--map-radius,16px);box-shadow:0 -4px 20px rgba(0,0,0,0.2);transition:height 0.3s ease-out;display:flex;flex-direction:column;overflow:hidden;max-height:calc(100% - var(--legend-sheet-top-reserve,80px));} +.legend-mobile-sheet{position:absolute;bottom:0;inset-inline-start:var(--map-fab-sheet-inset,var(--map-fab-beside,0px));inset-inline-end:0;z-index:1000;background:white;border-top-left-radius:var(--map-radius,16px);border-top-right-radius:var(--map-radius,16px);box-shadow:0 -4px 20px rgba(0,0,0,0.2);transition:height 0.3s ease-out;display:flex;flex-direction:column;overflow:hidden;max-height:calc(100% - var(--legend-sheet-top-reserve,80px));} /* While a finger is resizing the sheet the height must be applied INSTANTLY — with the 0.3s transition left on, the measured height lagged the finger by 14-30px (mobile-sheet-probe baseline 2026-09-16) and the release logic read a @@ -779,6 +788,43 @@ onBeforeUnmount(() => { _lmHost = null }) +// ── The sheet tells the layout where it is (the bottom-of-map standard) ─── +// The round menu button lives in the LAYOUT, outside this shadow root, and sits just above the sheet on the +// start side, never over it. CSS variables do not cross a shadow root upward, so the sheet announces itself: +// 'dxmaps:legend-sheet' from the sheet element, bubbling and composed. The layout keys on that element +// (composedPath()[0]) and measures its box itself, since a map need not end at the viewport's bottom (Simple +// Research's grid peeks out below its map). state 'gone' (no box: unmounting, or the desktop slot) drops it. +const LEGEND_SHEET_EVENT = 'dxmaps:legend-sheet' +let _sheetEventRaf = 0 +function emitSheetEvent(gone) { + const sheet = sheetRef.value + if (!sheet || !sheet.isConnected) return + const h = gone ? 0 : sheet.offsetHeight + const state = h > 0 ? (legendState.value === 'collapsed' ? 'collapsed' : 'open') : 'gone' + sheet.dispatchEvent(new CustomEvent(LEGEND_SHEET_EVENT, { bubbles: true, composed: true, detail: { height: h, collapsedHeight: COLLAPSED_PX, state } })) +} +// One event per frame, however many resizes a drag or the 0.3s height transition produces; the observer +// below sees every frame of a height change, and transitionend covers a change that moves without resizing. +function queueSheetEvent() { + if (_sheetEventRaf || typeof requestAnimationFrame === 'undefined') return + _sheetEventRaf = requestAnimationFrame(() => { _sheetEventRaf = 0; emitSheetEvent(false) }) +} +watch(legendState, () => queueSheetEvent()) +onMounted(() => { + const sheet = sheetRef.value + if (!sheet) return + sheet.addEventListener('transitionend', queueSheetEvent) + window.addEventListener('resize', queueSheetEvent) + queueSheetEvent() +}) +onBeforeUnmount(() => { + if (_sheetEventRaf) { cancelAnimationFrame(_sheetEventRaf); _sheetEventRaf = 0 } + const sheet = sheetRef.value + if (sheet) sheet.removeEventListener('transitionend', queueSheetEvent) + window.removeEventListener('resize', queueSheetEvent) + emitSheetEvent(true) // still attached here, so the layout hears it +}) + // ── Sheet height → host CSS var (keeps the engine's attribution visible) ─── // Writes --legend-sheet-h on the custom-element host (the CSS rule near the top // of the stylesheet lifts the bottom control corners by it). Only a sheet with a @@ -798,6 +844,7 @@ onMounted(() => { if (!_sheetVarTarget) return _observedSheet = sheet _sheetResizeObserver = new ResizeObserver(() => { + queueSheetEvent() const h = sheet.offsetHeight if (h <= 0) return _sheetVarTarget.style.setProperty(SHEET_H_VAR, `${h}px`) @@ -815,6 +862,31 @@ onBeforeUnmount(() => { _sheetVarTarget = null _observedSheet = null }) +// ── The credit shows, then folds to its (i) (the bottom-of-map standard) ─── +// On a narrow map both engines draw the licence credit compact: expanded at first, folded to an (i) at the +// map's first drag. On a phone that drag may never come, and the expanded credit then sits above the sheet +// for good. So CREDIT_FOLD_MS after it first shows expanded, fold it the way the engine's drag does (drop the +// -compact-show class); a tap on the (i) still expands it. The map may still be loading its style when the +// sheet mounts, so look every half second, for up to 30 s; stop once the credit is there and folded (a drag +// or a tap got there first) or is not compact at this width. +const CREDIT_FOLD_MS = 4000 +let _creditTimer = 0 +function watchCredit(triesLeft) { + _creditTimer = 0 + const sheet = sheetRef.value + const root = sheet && sheet.getRootNode ? sheet.getRootNode() : null + if (!root || !root.querySelector) return + const shown = root.querySelector('.maplibregl-ctrl-attrib.maplibregl-compact-show, .mapboxgl-ctrl-attrib.mapboxgl-compact-show') + if (shown) { + _creditTimer = setTimeout(() => { _creditTimer = 0; if (uiStore.isMobile) shown.classList.remove('maplibregl-compact-show', 'mapboxgl-compact-show') }, CREDIT_FOLD_MS) + return + } + const credit = root.querySelector('.maplibregl-ctrl-attrib, .mapboxgl-ctrl-attrib') + if (credit && !/attrib-empty/.test(credit.className)) return + if (triesLeft > 0) _creditTimer = setTimeout(() => watchCredit(triesLeft - 1), 500) +} +onMounted(() => watchCredit(60)) +onBeforeUnmount(() => { if (_creditTimer) { clearTimeout(_creditTimer); _creditTimer = 0 } })