From 7f9f155d2421226801b5989ebb6cd04c54777e62 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Wed, 30 Sep 2026 08:36:48 +0000 Subject: [PATCH 01/18] Phones: the bottom-of-map standard in the legend sheet The engine's credit moves from the top corner to the end side just above the sheet, and folds to its (i) four seconds after it first shows expanded. The logo corner sits beside the menu button, and MapLibre's optional logo control is hidden. The sheet announces itself with a 'dxmaps:legend-sheet' event (bubbling, composed), so the layout can lift its round menu button above it. The sheet takes the full width while the layout says it lifts the button (--map-fab-sheet-inset); otherwise it stays beside the button. The older corner rules are scoped to .lm-on, so they end with the last sheet. --- library/components/legends/LegendMobile.vue | 126 +++++++++++++++----- 1 file changed, 99 insertions(+), 27 deletions(-) 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 } })