diff --git a/library/components/legends/LegendMobile.vue b/library/components/legends/LegendMobile.vue index 479263bce..81e269913 100644 --- a/library/components/legends/LegendMobile.vue +++ b/library/components/legends/LegendMobile.vue @@ -23,19 +23,23 @@ 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. */ +/* ★ ATTRIBUTION PLACEMENT ON A PHONE MAP WITH THIS FOOTER SHEET (mobile pass 2026-09-30). The engine's credit + sits in the TOP corner on the START side (left; right in RTL), opposite the map's own control column, + compact: it shows expanded, then folds to its (i) 3 s after it first shows (script: CREDIT_FOLD_MS), and a + tap on the (i) opens it again. Its corner sits above the scroll-edge strips, so the tap always lands. The + sheet is flush at the map's bottom at FULL width while the layout lifts its round menu button above the + sheet (the layout says so with --map-fab-sheet-inset:0px, from the 'dxmaps:legend-sheet' event in the + script); a layout that does not lift the button leaves the sheet beside it (--map-fab-beside). MapLibre's + optional logo control goes; the credit, the licence term, stays. Every bottom placement of the credit + failed a reader (covered by the open sheet, floating mid-map, or pushing the legend off the map), which + is why it lives at the top here; a map with no footer sheet keeps it at the bottom-right. 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 +50,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,15 +66,18 @@ 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;} +: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;} /* !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. */ @@ -81,7 +87,8 @@ useShadowStyles(` 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);} + :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);z-index:4;} + :host(.lm-on) .maplibregl-ctrl-logo,.lm-on .maplibregl-ctrl-logo{display:none !important;} /* 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 @@ -103,7 +110,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 +786,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 +842,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 +860,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 runs across the top of +// the map for good, under the map's own top overlays. 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 = 3000 +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 } })