diff --git a/frontend/package.json b/frontend/package.json index d6e7e44..6a09775 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "1.0.1", + "version": "1.0.2", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 924b4da..3457542 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -420,10 +420,25 @@ async function refreshMap(): Promise { } } +let mapDestroyTimer = 0; async function onModeChange(mode: ViewMode): Promise { if (mode !== 'map') view.applyLayout(); // timeline visible (timeline or split) - if (mode === 'timeline') mapView.closePanel(); // map hidden — drop its floating card - if (mode !== 'timeline') await refreshMap(); // map visible (map or split) + if (mode === 'timeline') { + mapView.closePanel(); // map hidden — drop its floating card + // Free the Yandex map (tiles/canvas/GPU — the bulk of the tab's memory) a + // couple of seconds after it's closed; cancelled if a map view returns, so + // quick График⇄Карта toggles don't thrash a re-init. + window.clearTimeout(mapDestroyTimer); + mapDestroyTimer = window.setTimeout(() => { + mapView.destroy(); + // The fresh map/ObjectManager will be empty, so drop the "already loaded" + // key — the next open re-fetches + re-renders instead of short-circuiting. + mapLoadedKey = ''; + }, 2000); + } else { + window.clearTimeout(mapDestroyTimer); // map visible again — keep it + await refreshMap(); // map visible (map or split); ensureInit rebuilds if destroyed + } } const viewModes = createViewModes( diff --git a/frontend/src/map.ts b/frontend/src/map.ts index 56873de..b839e2d 100644 --- a/frontend/src/map.ts +++ b/frontend/src/map.ts @@ -63,6 +63,9 @@ export interface MapView { ensureInit(): Promise; invalidateSize(): void; closePanel(): void; + /** Destroy the Yandex map and free its tiles/canvas/GPU memory. The next + * ensureInit() re-creates it from scratch. */ + destroy(): void; /** Pan + zoom the map to the surface's location (its cluster). No-op if it has no coords. */ focusBoard(boardId: string): void; } @@ -633,6 +636,20 @@ export function createMapView(els: MapElements, apiKey: string): MapView { closePanel(): void { closeMapPanel(); }, + destroy(): void { + closeMapPanel(); + // map.destroy() tears down Yandex's DOM inside #map and releases its tile + // cache / canvas / GPU textures. Our legend/counter overlays are siblings + // of #map, so they survive. Reset state so ensureInit() rebuilds cleanly. + if (map) { + try { map.destroy(); } catch { /* already torn down */ } + } + map = null; + objectManager = null; + initPromise = null; + pendingFocus = null; + pending = null; + }, focusBoard(boardId: string): void { pendingFocus = boardId; applyFocus(); // applies now if already rendered, else waits for the next draw diff --git a/frontend/src/styles.css b/frontend/src/styles.css index ab07240..8434610 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -327,8 +327,11 @@ input::placeholder { color: var(--text-muted); } display: flex; align-items: center; gap: 6px; padding: 0 12px; box-sizing: border-box; overflow: hidden; background: var(--bg); border-bottom: 1px solid var(--border); + container-type: inline-size; } .ci-label { flex: 0 0 auto; font-size: 11px; color: var(--text-muted); white-space: nowrap; } +/* Narrow column: drop the "Показывать" label so the three pills still fit. */ +@container (max-width: 255px) { #chart-wrap #col-info .ci-label { display: none; } } .ci-toggle { flex: 0 0 auto; border: none; cursor: pointer; white-space: nowrap; font-size: 12px; font-weight: 500; padding: 5px 11px; border-radius: 999px; @@ -770,10 +773,14 @@ html.role-pending .manager-only { display: none !important; } /* Row hover */ .vis-label.row-hover, .vis-group.row-hover { background-color: var(--row-hover) !important; } /* Fixed/resizable label column */ -.vis-label .vis-inner { +/* #timeline lifts specificity above vis-timeline's own `.vis-labelset .vis-label + .vis-inner { padding: 5px }`, so the right padding actually applies. */ +#timeline .vis-label .vis-inner { display: inline-block; + box-sizing: border-box; min-width: var(--label-w); max-width: var(--label-w); + padding-left: 7px; padding-right: 7px; /* keep the address off the column borders */ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; } diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts index 8de4435..c4e6e81 100644 --- a/frontend/src/timeline.ts +++ b/frontend/src/timeline.ts @@ -54,7 +54,7 @@ export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [ const MONTH_BG_ID = '__month_hover_bg'; const CUR_MONTH_BG_ID = '__cur_month_bg'; -const MIN_LABEL_W = 140; +const MIN_LABEL_W = 200; // fits the three column-info pills (~188px) without clipping // Uniform 3-letter month labels (moment's ru `MMM` mixes full/abbreviated with // dots — "март", "май", "нояб.", "дек."). scale is 'month' at every zoom step.