From 17b70b1f441d4dc84a0fbf0b4dc463ed2e47990a Mon Sep 17 00:00:00 2001 From: aaverbitskiy Date: Mon, 17 Aug 2026 20:56:33 +0000 Subject: [PATCH] =?UTF-8?q?=D0=9A=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=93=D0=BE=D1=80=D0=BE=D0=B4/=D0=90=D0=B4=D1=80=D0=B5=D1=81/?= =?UTF-8?q?=D0=9A=D0=BE=D0=B4=20+=20=D0=BE=D1=84=D0=BE=D1=80=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=88=D0=BA=D0=B0=D0=BB=D1=8B=20?= =?UTF-8?q?=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1) Первая колонка графика: в углу — кнопки «Показывать» Город/Адрес/Код (стиль пилюль, активная — фиолетовая). Строка собирается из включённых сущностей, разделённых кружком 7px; код без скобок; адрес тёмный, город/код приглушённые. «Адрес» (или последний активный) нельзя снять — строка не пустеет. Доступно всем ролям, выбор в localStorage. Лёгкое обновление подписей (view.refreshLabels — без пересборки баров). 2) Временная шкала: единые 3-буквенные месяцы (format.minorLabels/majorLabels), год индиго и жирнее (.vis-text.vis-major), подсветка текущего месяца (фон-элемент cur-month-bg). Красная линия «сегодня» без изменений. Co-Authored-By: Claude Opus 4.8 --- frontend/index.html | 6 ++++ frontend/package.json | 2 +- frontend/src/main.ts | 35 ++++++++++++++++++++- frontend/src/styles.css | 28 +++++++++++++++++ frontend/src/timeline.ts | 68 +++++++++++++++++++++++++++++++++++++--- 5 files changed, 132 insertions(+), 7 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 2cdfcf5..43deb59 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -164,6 +164,12 @@
+
+ Показывать + + + +
12М
diff --git a/frontend/package.json b/frontend/package.json index 51deba5..d6e7e44 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "mapdash-frontend", "private": true, - "version": "0.2.17", + "version": "1.0.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 8f5fdf3..924b4da 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -136,17 +136,32 @@ const mapView = createMapView( let lastBoards: Board[] = []; let lastBookings: Booking[] = []; +// Which info elements the first column shows — toggled in the column header, +// persisted per browser. Available to every role (address/code already shown to +// anonymous; city is not sensitive). A row is never empty (see the toggle handler). +const CI_LS = 'mapdash.infoCols'; +type InfoCols = { city: boolean; address: boolean; code: boolean }; +const infoCols: InfoCols = ((): InfoCols => { + try { + const s = JSON.parse(localStorage.getItem(CI_LS) || 'null'); + if (s && typeof s.city === 'boolean' && typeof s.address === 'boolean' && typeof s.code === 'boolean') return s; + } catch { /* fall through to default */ } + return { city: false, address: true, code: true }; +})(); + function flags(): RenderFlags { + const cols = { showCity: infoCols.city, showAddress: infoCols.address, showCode: infoCols.code }; // Anonymous tier: no labels (data is blanked anyway) and no collision // highlighting, so every bar renders in the single neutral status colour. if (!isManager()) { - return { withBrand: false, withCompany: false, withCollisions: false, managerView: false }; + return { withBrand: false, withCompany: false, withCollisions: false, managerView: false, ...cols }; } return { withBrand: el.showBrand.checked, withCompany: el.showCompany.checked, withCollisions: el.showCollisions.checked, managerView: true, + ...cols, }; } @@ -482,6 +497,24 @@ el.zoomField.addEventListener('mouseleave', () => { zoomCollapseTimer = window.setTimeout(() => el.zoomField.classList.remove('expanded'), 1000); }); +// Column-info header: toggle city / address / code shown in the first column. +const ciButtons = Array.from(document.querySelectorAll('.ci-toggle')); +function syncInfoButtons(): void { + for (const btn of ciButtons) btn.classList.toggle('is-on', infoCols[btn.dataset.part as keyof InfoCols]); +} +for (const btn of ciButtons) { + btn.addEventListener('click', () => { + const part = btn.dataset.part as keyof InfoCols; + // Never leave the row empty: can't turn off the last remaining part. + if (infoCols[part] && !(['city', 'address', 'code'] as (keyof InfoCols)[]).some((k) => k !== part && infoCols[k])) return; + infoCols[part] = !infoCols[part]; + localStorage.setItem(CI_LS, JSON.stringify(infoCols)); + syncInfoButtons(); + view.refreshLabels(flags()); + }); +} +syncInfoButtons(); + // Clicking a surface in the timeline's left column flies the map to it: switch to // the combined "Карта и график" view if only the timeline is open, then focus the // surface's cluster. Works for anonymous and manager users alike. diff --git a/frontend/src/styles.css b/frontend/src/styles.css index a6f5916..ab07240 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -35,6 +35,7 @@ --row-odd: #ffffff; --row-hover: #fff3cd; --month-hover: rgba(79, 70, 229, .08); + --cur-month: rgba(79, 70, 229, .07); --tip-bg: var(--gray-9); --tip-text: #ffffff; --tip-sub: var(--gray-4); @@ -60,6 +61,7 @@ --row-odd: #0f1420; --row-hover: #33361f; --month-hover: rgba(91, 141, 239, .12); + --cur-month: rgba(91, 141, 239, .09); --tip-bg: #0b0f18; } @@ -318,6 +320,30 @@ input::placeholder { color: var(--text-muted); } border: 4px solid transparent; border-bottom-color: var(--accent); border-top: 0; } +/* Column-info header: fills the empty top-left corner (above the row labels). + Toggle which of city / address / code each first-column row shows. */ +#chart-wrap #col-info { + position: absolute; top: 0; left: 0; width: var(--label-w); z-index: 21; + 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); +} +.ci-label { flex: 0 0 auto; font-size: 11px; color: var(--text-muted); white-space: nowrap; } +.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; + background: var(--accent-weak); color: var(--accent); + transition: background var(--transition), color var(--transition); +} +.ci-toggle.is-on { background: var(--accent); color: #fff; } +.ci-toggle:not(.is-on):hover { background: var(--accent-ring); } +/* Row label parts, separated by a 7px dot; address is primary, city/code muted. */ +.ci-cell { display: inline-flex; align-items: center; flex-wrap: wrap; } +.ci-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: #c1c4ef; margin: 0 9px; flex: 0 0 auto; } +.ci-city { color: var(--text-muted); } +.ci-addr { color: var(--text); } +.ci-code { color: var(--text-muted); font-variant-numeric: tabular-nums; } + /* checkbox dropdown filter */ .dropdown { position: relative; } .dropdown-btn { @@ -756,6 +782,7 @@ html.role-pending .manager-only { display: none !important; } .vis-panel.vis-center, .vis-panel.vis-left, .vis-panel.vis-right, .vis-panel.vis-top, .vis-panel.vis-bottom { border-color: var(--border); } .vis-time-axis .vis-text { color: var(--text-muted); } +.vis-time-axis .vis-text.vis-major { color: var(--accent); font-weight: 500; } .vis-time-axis .vis-grid.vis-minor { border-color: var(--grid-line); } .vis-time-axis .vis-grid.vis-major { border-color: var(--border-strong); } .vis-labelset .vis-label, @@ -774,6 +801,7 @@ html.role-pending .manager-only { display: none !important; } /* Hovered-month column highlight */ .vis-item.vis-background.month-hover-bg { background-color: var(--month-hover); } +.vis-item.vis-background.cur-month-bg { background-color: var(--cur-month); } /* Rich hover tooltip for timeline bars (structured HTML, built in timeline.ts). Shares the dark look of the map tooltip (.map-tip) for a consistent feel. */ diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts index be64cad..8de4435 100644 --- a/frontend/src/timeline.ts +++ b/frontend/src/timeline.ts @@ -14,6 +14,10 @@ export interface RenderFlags { // Manager tier: show the rich tooltip (title, task №, status, manager, Planfix // hint). Anonymous tier gets a minimal tooltip with only the date range. managerView: boolean; + // Which info elements the first column shows (toggled in the column header). + showCity: boolean; + showAddress: boolean; + showCode: boolean; } export interface TimelineElements { @@ -24,6 +28,19 @@ export interface TimelineElements { colResizer: HTMLElement; } +// First-column label: city · address · code (only the parts enabled in the +// header), separated by a small dot. Falls back to the address so a row is never +// empty (the header also forbids turning every part off). +function groupContent(b: Board, flags: RenderFlags): string { + const dot = ''; + const parts: string[] = []; + if (flags.showCity && b.board_city) parts.push(`${escapeHtml(b.board_city)}`); + if (flags.showAddress && b.board_address) parts.push(`${escapeHtml(b.board_address)}`); + if (flags.showCode && b.board_id) parts.push(`${escapeHtml(b.board_id)}`); + if (!parts.length) parts.push(`${escapeHtml(b.board_address)}`); + return `${parts.join(dot)}`; +} + // Clicking a bar opens the corresponding Planfix task. const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/'; @@ -36,7 +53,21 @@ 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; + +// Uniform 3-letter month labels (moment's ru `MMM` mixes full/abbreviated with +// dots — "март", "май", "нояб.", "дек."). scale is 'month' at every zoom step. +const MONTHS_SHORT = ['янв', 'фев', 'мар', 'апр', 'май', 'июн', 'июл', 'авг', 'сен', 'окт', 'ноя', 'дек']; +function axisMinorLabel(date: unknown, scale: string): string { + const d = new Date((date as { valueOf(): number }).valueOf()); + if (scale === 'month') return MONTHS_SHORT[d.getMonth()]!; + if (scale === 'year') return String(d.getFullYear()); + return String(d.getDate()); +} +function axisMajorLabel(date: unknown): string { + return String(new Date((date as { valueOf(): number }).valueOf()).getFullYear()); +} const MAX_LABEL_W = 760; // "2026-06-01" -> "01.06.2026" @@ -67,6 +98,9 @@ export interface TimelineView { render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void; /** Re-run the last render (e.g. after colours changed). */ refresh(): void; + /** Update only the first-column labels from new info-column flags (light — + * doesn't rebuild bars). */ + refreshLabels(flags: RenderFlags): void; /** Re-apply row height / centring from appearance and redraw. */ applyLayout(): void; setScale(days: number): void; @@ -79,7 +113,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) const itemsDS = new DataSet(); let lastBoards: Board[] = []; let lastBookings: Booking[] = []; - let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true, managerView: true }; + let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true, managerView: true, showCity: false, showAddress: true, showCode: true }; let onBoardClick: ((boardId: string) => void) | null = null; const options: TimelineOptions = { @@ -96,6 +130,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) orientation: { axis: 'top' }, tooltip: { followMouse: false, delay: 0 }, locale: 'ru', + format: { minorLabels: axisMinorLabel, majorLabels: axisMajorLabel }, xss: { disabled: true }, }; @@ -234,10 +269,14 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) // Keep the floating scale control just below the time axis (5px gap) so it // never overlaps the axis; the axis height varies, so measure it each redraw. function positionZoomField(): void { - const zf = document.getElementById('zoom-field'); const axis = el.timelineEl.querySelector('.vis-panel.vis-top'); - if (!zf || !axis) return; - zf.style.top = axis.getBoundingClientRect().bottom - el.chartWrap.getBoundingClientRect().top + 5 + 'px'; + if (!axis) return; + const axisBottom = axis.getBoundingClientRect().bottom - el.chartWrap.getBoundingClientRect().top; + const zf = document.getElementById('zoom-field'); + if (zf) zf.style.top = axisBottom + 5 + 'px'; + // The column-info header fills the empty top-left corner (above the labels). + const ci = document.getElementById('col-info'); + if (ci) ci.style.height = axisBottom + 'px'; } timeline.on('changed', positionZoomField); window.addEventListener('resize', positionZoomField); @@ -377,7 +416,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) const groups = boards.map((b, idx) => ({ id: b.board_id, - content: escapeHtml(b.board_address) + ' (' + escapeHtml(b.board_id) + ')', + content: groupContent(b, flags), order: idx, className: idx % 2 === 0 ? 'row-even' : 'row-odd', })); @@ -450,6 +489,16 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) groupsDS.add(groups); itemsDS.clear(); itemsDS.add(items); + // Persistent tint on the current-month column (aligned to the axis months, + // which the hover highlight also keys off UTC month boundaries). + const now = new Date(); + itemsDS.add({ + id: CUR_MONTH_BG_ID, + type: 'background', + start: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)), + end: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1)), + className: 'cur-month-bg', + } as never); lastMonthKey = null; if (fit) timeline.fit({ animation: false }); @@ -461,6 +510,14 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags); } + // Light: only rewrite the group (row-label) contents — the bars/collisions + // stay put, so toggling a column doesn't rebuild the whole timeline. + function refreshLabels(f: RenderFlags): void { + lastFlags = f; + if (!lastBoards.length) return; + groupsDS.update(lastBoards.map((b) => ({ id: b.board_id, content: groupContent(b, f) }))); + } + function applyLayout(): void { timeline.setOptions({ margin: { item: { horizontal: 2, vertical: 0 } } }); timeline.redraw(); @@ -477,6 +534,7 @@ export function createTimelineView(el: TimelineElements, appearance: Appearance) return { render, refresh, + refreshLabels, applyLayout, setScale, setOnBoardClick(fn: (boardId: string) => void): void { onBoardClick = fn; },