-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/appearance.ts b/frontend/src/appearance.ts
new file mode 100644
index 0000000..79423c4
--- /dev/null
+++ b/frontend/src/appearance.ts
@@ -0,0 +1,229 @@
+// Appearance settings: per-status bar colours, row/bar sizing, font sizes.
+// Persisted in localStorage; applied via CSS variables + per-item inline styles.
+
+export interface Appearance {
+ /** Colour per task_status (hex #rrggbb). Unknown statuses fall back to defaultColor. */
+ colors: Record
;
+ /** Colour for bars flagged as a date-collision (overrides the status colour). */
+ collisionColor: string;
+ /** Fallback colour for statuses without an explicit entry. */
+ defaultColor: string;
+ /** Height of a board row (container), in pixels. */
+ rowHeightPx: number;
+ /** Bar height as a percentage of the row height. */
+ barHeightPct: number;
+ /** Font size of the text inside bars, in pixels. */
+ barFontPx: number;
+ /** Font size of the address/code column (first column), in pixels. */
+ labelFontPx: number;
+}
+
+const LS_KEY = 'mapdash.appearance.v1';
+
+/** Sensible out-of-the-box colours for the known statuses. */
+export const DEFAULT_STATUS_COLORS: Record = {
+ 'РК. Новая': '#2e66d6',
+ 'РК. Размещено': '#2e66d6',
+ 'РК. Макет РИМ согласован': '#2e66d6',
+ 'РК. Ожидает монтаж': '#2e66d6',
+ 'РК. Ожидает демонтаж': '#2e66d6',
+ 'РК. Ожидает перемонтаж': '#2e66d6',
+ 'РК. Архив': '#2e66d6',
+};
+
+export function defaultAppearance(): Appearance {
+ return {
+ colors: { ...DEFAULT_STATUS_COLORS },
+ collisionColor: '#BA55D3',
+ defaultColor: '#2e66d6',
+ rowHeightPx: 30,
+ barHeightPct: 62,
+ barFontPx: 11,
+ labelFontPx: 12,
+ };
+}
+
+export function loadAppearance(): Appearance {
+ const base = defaultAppearance();
+ try {
+ const raw = localStorage.getItem(LS_KEY);
+ if (!raw) return base;
+ const saved = JSON.parse(raw) as Partial;
+ return {
+ ...base,
+ ...saved,
+ colors: { ...base.colors, ...(saved.colors ?? {}) },
+ };
+ } catch {
+ return base;
+ }
+}
+
+export function saveAppearance(a: Appearance): void {
+ try {
+ localStorage.setItem(LS_KEY, JSON.stringify(a));
+ } catch {
+ /* storage unavailable — settings simply won't persist */
+ }
+}
+
+export function barHeightPx(a: Appearance): number {
+ return Math.max(1, Math.round((a.rowHeightPx * a.barHeightPct) / 100));
+}
+
+/** Symmetric top/bottom gap that centres the bar and yields rowHeightPx total. */
+export function marginVertical(a: Appearance): number {
+ return Math.max(0, Math.round((a.rowHeightPx - barHeightPx(a)) / 2));
+}
+
+export function colorForStatus(a: Appearance, status: string): string {
+ return a.colors[status] || a.defaultColor;
+}
+
+/** Push size/font settings into CSS custom properties on :root. */
+export function applyCssVars(a: Appearance): void {
+ const root = document.documentElement.style;
+ root.setProperty('--row-h', a.rowHeightPx + 'px');
+ root.setProperty('--bar-h', barHeightPx(a) + 'px');
+ root.setProperty('--bar-font', a.barFontPx + 'px');
+ root.setProperty('--label-font', a.labelFontPx + 'px');
+}
+
+// ---- settings menu UI ----------------------------------------------------
+
+function numberRow(labelText: string, value: number, min: number, max: number, onInput: (v: number) => void): HTMLElement {
+ const row = document.createElement('label');
+ row.className = 'decor-row';
+ const span = document.createElement('span');
+ span.textContent = labelText;
+ const input = document.createElement('input');
+ input.type = 'number';
+ input.min = String(min);
+ input.max = String(max);
+ input.value = String(value);
+ input.addEventListener('input', () => {
+ const v = parseInt(input.value, 10);
+ if (!Number.isNaN(v)) onInput(Math.max(min, Math.min(max, v)));
+ });
+ row.appendChild(span);
+ row.appendChild(input);
+ return row;
+}
+
+function colorRow(labelText: string, value: string, onInput: (v: string) => void): HTMLElement {
+ const row = document.createElement('label');
+ row.className = 'decor-row';
+ const span = document.createElement('span');
+ span.textContent = labelText;
+ const input = document.createElement('input');
+ input.type = 'color';
+ input.value = value;
+ input.addEventListener('input', () => onInput(input.value));
+ row.appendChild(span);
+ row.appendChild(input);
+ return row;
+}
+
+function section(title: string): HTMLElement {
+ const s = document.createElement('div');
+ s.className = 'decor-section';
+ const h = document.createElement('div');
+ h.className = 'decor-section-title';
+ h.textContent = title;
+ s.appendChild(h);
+ return s;
+}
+
+/**
+ * (Re)build the appearance menu contents into `panel`.
+ * `statuses` — union of statuses to expose colour pickers for.
+ * `onChange` — called after every edit (already persisted).
+ */
+export function buildAppearanceMenu(
+ panel: HTMLElement,
+ statuses: string[],
+ a: Appearance,
+ onChange: () => void,
+): void {
+ panel.innerHTML = '';
+
+ // 1) Colours
+ const colours = section('Цвета баров');
+ const known = Object.keys(DEFAULT_STATUS_COLORS);
+ const all = Array.from(new Set([...known, ...statuses]));
+ for (const st of all) {
+ colours.appendChild(
+ colorRow(st, colorForStatus(a, st), (v) => {
+ a.colors[st] = v;
+ saveAppearance(a);
+ onChange();
+ }),
+ );
+ }
+ colours.appendChild(
+ colorRow('Коллизия', a.collisionColor, (v) => {
+ a.collisionColor = v;
+ saveAppearance(a);
+ onChange();
+ }),
+ );
+ colours.appendChild(
+ colorRow('Прочие статусы', a.defaultColor, (v) => {
+ a.defaultColor = v;
+ saveAppearance(a);
+ onChange();
+ }),
+ );
+ panel.appendChild(colours);
+
+ // 2) Sizes
+ const sizes = section('Размеры строк и баров');
+ sizes.appendChild(
+ numberRow('Высота строки, px', a.rowHeightPx, 8, 120, (v) => {
+ a.rowHeightPx = v;
+ saveAppearance(a);
+ onChange();
+ }),
+ );
+ sizes.appendChild(
+ numberRow('Высота бара, % от строки', a.barHeightPct, 10, 100, (v) => {
+ a.barHeightPct = v;
+ saveAppearance(a);
+ onChange();
+ }),
+ );
+ panel.appendChild(sizes);
+
+ // 3) Fonts
+ const fonts = section('Размер шрифта');
+ fonts.appendChild(
+ numberRow('В барах, px', a.barFontPx, 6, 32, (v) => {
+ a.barFontPx = v;
+ saveAppearance(a);
+ onChange();
+ }),
+ );
+ fonts.appendChild(
+ numberRow('В колонке адресов, px', a.labelFontPx, 6, 32, (v) => {
+ a.labelFontPx = v;
+ saveAppearance(a);
+ onChange();
+ }),
+ );
+ panel.appendChild(fonts);
+
+ // Reset
+ const reset = document.createElement('button');
+ reset.type = 'button';
+ reset.className = 'decor-reset';
+ reset.textContent = 'Сбросить к значениям по умолчанию';
+ reset.addEventListener('click', () => {
+ const d = defaultAppearance();
+ Object.assign(a, d);
+ a.colors = { ...d.colors };
+ saveAppearance(a);
+ buildAppearanceMenu(panel, statuses, a, onChange);
+ onChange();
+ });
+ panel.appendChild(reset);
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index b7484be..6b846c1 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -5,6 +5,7 @@ import { api } from './api';
import type { Board, Booking, Filters } from './types';
import { createDropdown } from './dropdown';
import { createTimelineView, zoomSteps, type RenderFlags } from './timeline';
+import { loadAppearance, applyCssVars, buildAppearanceMenu } from './appearance';
const el = {
search: document.getElementById('search') as HTMLInputElement,
@@ -19,17 +20,25 @@ const el = {
zoomLabel: document.getElementById('zoom-label') as HTMLElement,
chartWrap: document.getElementById('chart-wrap') as HTMLElement,
colResizer: document.getElementById('col-resizer') as HTMLElement,
+ decorBtn: document.getElementById('decor-btn') as HTMLButtonElement,
+ decorMenu: document.getElementById('decor-menu') as HTMLElement,
};
+const appearance = loadAppearance();
+applyCssVars(appearance);
+
const cityDropdown = createDropdown('city');
const dimensionDropdown = createDropdown('dimension');
-const view = createTimelineView({
- timelineEl: el.timelineEl,
- chartWrap: el.chartWrap,
- tooltip: el.tooltip,
- empty: el.empty,
- colResizer: el.colResizer,
-});
+const view = createTimelineView(
+ {
+ timelineEl: el.timelineEl,
+ chartWrap: el.chartWrap,
+ tooltip: el.tooltip,
+ empty: el.empty,
+ colResizer: el.colResizer,
+ },
+ appearance,
+);
let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
@@ -50,6 +59,29 @@ function currentFilters(): Filters {
};
}
+// ---- appearance menu ----
+function currentStatuses(): string[] {
+ return Array.from(new Set(lastBookings.map((b) => b.task_status).filter(Boolean)));
+}
+
+function rebuildDecorMenu(): void {
+ buildAppearanceMenu(el.decorMenu, currentStatuses(), appearance, () => {
+ applyCssVars(appearance);
+ view.applyLayout();
+ view.refresh();
+ });
+}
+
+el.decorBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ el.decorMenu.classList.toggle('open');
+});
+document.addEventListener('click', (e) => {
+ if (!el.decorMenu.contains(e.target as Node) && e.target !== el.decorBtn) {
+ el.decorMenu.classList.remove('open');
+ }
+});
+
async function loadMeta(): Promise {
const meta = await api.meta();
cityDropdown.setValues(meta.cities, 'Все города');
@@ -64,6 +96,7 @@ async function loadData(fit: boolean): Promise {
lastBookings = bookings;
view.render(boards, bookings, fit, flags());
el.status.textContent = `Бордов: ${boards.length}, бронирований: ${bookings.length}`;
+ rebuildDecorMenu(); // status list may have changed
}
let debounceTimer: number | undefined;
@@ -88,9 +121,10 @@ el.zoomSlider.addEventListener('input', () => {
});
async function init(): Promise {
+ rebuildDecorMenu();
+ view.applyLayout();
await loadMeta();
await loadData(true);
- // Default zoom on first load: 12 months (index 3), applied after the initial fit.
const defaultZoomIdx = 3;
el.zoomSlider.value = String(defaultZoomIdx);
el.zoomLabel.textContent = zoomSteps[defaultZoomIdx]!.label;
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 8e7411b..ca5eee0 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -6,13 +6,14 @@
--row-odd: #FFFFFF;
--row-hover: #FFECB3;
--month-hover: rgba(65, 105, 225, 0.10);
+ /* appearance (overridden at runtime by appearance.ts) */
+ --row-h: 30px;
+ --bar-h: 18px;
+ --bar-font: 11px;
+ --label-font: 12px;
}
* { box-sizing: border-box; }
-html, body {
- height: 100%;
- margin: 0;
- overflow: hidden;
-}
+html, body { height: 100%; margin: 0; overflow: hidden; }
body {
display: flex;
flex-direction: column;
@@ -20,26 +21,35 @@ body {
background: #fff;
color: #222;
}
+
header {
flex: 0 0 auto;
- padding: 14px 20px;
+ padding: 12px 16px;
border-bottom: 1px solid #e2e2e2;
display: flex;
- gap: 18px;
- align-items: center;
+ gap: 14px;
+ align-items: stretch;
flex-wrap: wrap;
background: #fff;
z-index: 20;
}
-.brand {
+
+/* Header groups (four boxed blocks) */
+.hgroup {
display: flex;
- flex-direction: column;
- justify-content: center;
- line-height: 1.2;
- margin-right: 8px;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 14px;
+ background: #f2f2f2;
+ border: 1px solid #e2e2e2;
+ border-radius: 8px;
}
+.hgroup-brand { background: #eaeaea; }
+
+.brand { display: flex; flex-direction: column; justify-content: center; line-height: 1.2; }
.brand-name { font-size: 18px; font-weight: 700; white-space: nowrap; }
.brand-sub { font-size: 15px; font-weight: 400; color: #666; white-space: nowrap; }
+
.field { display: flex; flex-direction: column; gap: 4px; }
.field label { font-size: 11px; color: #666; text-transform: uppercase; letter-spacing: .03em; }
input[type=text] {
@@ -53,15 +63,9 @@ input[type=text] {
.checkbox-field { flex-direction: row; align-items: center; gap: 6px; align-self: center; }
.checkbox-field label {
- display: flex;
- align-items: center;
- gap: 6px;
- font-size: 13px;
- color: #222;
- text-transform: none;
- letter-spacing: normal;
- cursor: pointer;
- white-space: nowrap;
+ display: flex; align-items: center; gap: 6px;
+ font-size: 13px; color: #222; text-transform: none; letter-spacing: normal;
+ cursor: pointer; white-space: nowrap;
}
.checkbox-field input[type=checkbox] { cursor: pointer; }
@@ -71,154 +75,126 @@ input[type=text] {
/* checkbox dropdown filter */
.dropdown { position: relative; }
.dropdown-btn {
- border: 1px solid #ccc;
- border-radius: 4px;
- background: #fff;
- padding: 6px 10px;
- font-size: 13px;
- min-width: 180px;
- text-align: left;
- cursor: pointer;
- display: flex;
- justify-content: space-between;
- align-items: center;
- gap: 8px;
+ border: 1px solid #ccc; border-radius: 4px; background: #fff;
+ padding: 6px 10px; font-size: 13px; min-width: 180px; text-align: left;
+ cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 8px;
}
.dropdown-btn:hover { border-color: #999; }
.dropdown-btn .caret { font-size: 10px; color: #888; }
.dropdown-panel {
- display: none;
- position: absolute;
- top: calc(100% + 4px);
- left: 0;
- background: #fff;
- border: 1px solid #ccc;
- border-radius: 4px;
- box-shadow: 0 4px 14px rgba(0,0,0,.12);
- padding: 6px 0;
- min-width: 220px;
- max-height: 280px;
- overflow-y: auto;
- z-index: 30;
+ display: none; position: absolute; top: calc(100% + 4px); left: 0;
+ background: #fff; border: 1px solid #ccc; border-radius: 4px;
+ box-shadow: 0 4px 14px rgba(0,0,0,.12); padding: 6px 0;
+ min-width: 220px; max-height: 280px; overflow-y: auto; z-index: 30;
}
.dropdown.open .dropdown-panel { display: block; }
.dropdown-panel label {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 5px 12px;
- font-size: 13px;
- cursor: pointer;
- white-space: nowrap;
+ display: flex; align-items: center; gap: 8px; padding: 5px 12px;
+ font-size: 13px; cursor: pointer; white-space: nowrap;
}
.dropdown-panel label:hover { background: #f4f4f4; }
.dropdown-panel .dp-actions {
- display: flex;
- gap: 10px;
- padding: 4px 12px 8px;
- border-bottom: 1px solid #eee;
- margin-bottom: 4px;
-}
-.dropdown-panel .dp-actions a {
- font-size: 11px;
- color: #567;
- cursor: pointer;
- text-decoration: underline;
+ display: flex; gap: 10px; padding: 4px 12px 8px;
+ border-bottom: 1px solid #eee; margin-bottom: 4px;
}
+.dropdown-panel .dp-actions a { font-size: 11px; color: #567; cursor: pointer; text-decoration: underline; }
-#chart-wrap {
- flex: 1 1 auto;
- min-height: 0;
- position: relative;
- overflow: hidden;
+/* Оформление button + context menu */
+.decor-wrap { position: relative; align-self: center; }
+.decor-btn {
+ border: 1px solid #ccc; border-radius: 4px; background: #fff;
+ padding: 8px 14px; font-size: 13px; cursor: pointer; white-space: nowrap;
}
+.decor-btn:hover { border-color: #999; }
+.decor-menu {
+ display: none; position: absolute; top: calc(100% + 6px); right: 0;
+ background: #fff; border: 1px solid #ccc; border-radius: 6px;
+ box-shadow: 0 6px 20px rgba(0,0,0,.15); padding: 10px 12px; z-index: 50;
+ width: max-content; max-width: min(420px, 92vw); max-height: calc(100vh - 96px); overflow-y: auto;
+}
+.decor-menu.open { display: block; }
+.decor-section { margin-bottom: 22px; padding-bottom: 14px; border-bottom: 1px solid #eee; }
+.decor-section:last-of-type { border-bottom: none; margin-bottom: 8px; }
+.decor-section-title {
+ font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .03em;
+ color: #666; margin: 6px 0 4px;
+}
+.decor-row {
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
+ padding: 3px 0; font-size: 13px;
+}
+.decor-row > span { color: #222; }
+.decor-row input[type=number] { width: 84px; padding: 4px 6px; border: 1px solid #ccc; border-radius: 4px; font-size: 13px; }
+.decor-row input[type=color] { width: 42px; height: 26px; padding: 0; border: 1px solid #ccc; border-radius: 4px; background: #fff; cursor: pointer; }
+.decor-reset { margin-top: 6px; width: 100%; padding: 6px; font-size: 12px; border: 1px solid #ccc; border-radius: 4px; background: #f7f7f7; cursor: pointer; }
+.decor-reset:hover { background: #efefef; }
+
+#chart-wrap { flex: 1 1 auto; min-height: 0; position: relative; overflow: hidden; }
#timeline { border: none; }
-/* vis-timeline overrides */
+/* vis-timeline bars: colour comes from each item's inline style (per status /
+ collision). Height and vertical centring come from --bar-h + symmetric vis
+ margin (see appearance.ts marginVertical); flex centres the text too. */
+/* The vis item spans the FULL row height and is transparent; the visible bar
+ is an inner div (.bar-inner) of --bar-h height, flex-centred. This makes
+ vertical centring independent of vis-timeline's own item positioning. */
.vis-item.vis-range {
- background-color: var(--bar-color);
- border-color: #2c50b0;
+ height: var(--row-h);
+ background-color: transparent !important;
+ border: none !important;
+ box-shadow: none !important;
+ padding: 0;
+ display: flex;
+ align-items: center;
+ cursor: pointer;
+}
+.vis-item.vis-range .vis-item-overflow { display: flex; align-items: center; height: 100%; width: 100%; overflow: hidden; }
+.vis-item.vis-range .vis-item-content { padding: 0; width: 100%; }
+.bar-inner {
+ height: var(--bar-h);
+ width: 100%;
+ box-sizing: border-box;
border-radius: 3px;
+ display: flex;
+ align-items: center;
+ padding: 0 6px;
+ color: #FFFFFF;
+ font-size: var(--bar-font);
+ line-height: 1;
+ white-space: nowrap;
+ overflow: hidden;
}
-.vis-item.vis-range:hover { filter: brightness(0.92); }
-/* Date-collision highlight (purple). Two-class specificity wins over plain range. */
-.vis-item.vis-range.collision {
- background-color: #BA55D3;
- border-color: #9932CC;
-}
-/* Archived bookings (task_status === "РК. Архив") — slate. */
-.vis-item.vis-range.archived {
- background-color: #708090;
- border-color: #5a6472;
-}
-.vis-item .vis-item-content { font-size: 11px; color: #FFFFFF; padding: 2px 6px; line-height: 1.3; white-space: nowrap; }
-.vis-labelset .vis-label { font-size: 12px; }
+.vis-item.vis-range:hover .bar-inner { filter: brightness(0.92); }
+.vis-foreground .vis-group { min-height: var(--row-h); }
+.vis-labelset .vis-label { font-size: var(--label-font); display: flex; align-items: center; min-height: var(--row-h); }
-/* Zebra striping for board rows. */
-.vis-label.row-even,
-.vis-group.row-even { background-color: var(--row-even); }
-.vis-label.row-odd,
-.vis-group.row-odd { background-color: var(--row-odd); }
-/* Row hover highlight (added via DOM classes). */
-.vis-label.row-hover,
-.vis-group.row-hover { background-color: var(--row-hover) !important; }
-/* Fixed/resizable label column: pin content width to --label-w so the vis left
- panel follows it exactly (both shrinking and growing beyond the longest label). */
+/* Zebra striping */
+.vis-label.row-even, .vis-group.row-even { background-color: var(--row-even); }
+.vis-label.row-odd, .vis-group.row-odd { background-color: var(--row-odd); }
+/* Row hover */
+.vis-label.row-hover, .vis-group.row-hover { background-color: var(--row-hover) !important; }
+/* Fixed/resizable label column */
.vis-label .vis-inner {
display: inline-block;
min-width: var(--label-w);
max-width: var(--label-w);
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- vertical-align: middle;
+ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle;
}
-/* Drag handle to resize the address/code column. */
+/* Column resize handle */
#col-resizer {
- position: absolute;
- top: 0;
- bottom: 0;
- width: 9px;
- margin-left: -4px;
- cursor: col-resize;
- z-index: 25;
- display: none;
-}
-#col-resizer::after {
- content: "";
- position: absolute;
- top: 0; bottom: 0; left: 4px;
- width: 1px;
- background: #ccc;
-}
-#col-resizer:hover::after,
-#col-resizer.dragging::after {
- background: var(--bar-color);
- width: 2px;
- left: 3px;
+ position: absolute; top: 0; bottom: 0; width: 9px; margin-left: -4px;
+ cursor: col-resize; z-index: 25; display: none;
}
+#col-resizer::after { content: ""; position: absolute; top: 0; bottom: 0; left: 4px; width: 1px; background: #ccc; }
+#col-resizer:hover::after, #col-resizer.dragging::after { background: var(--bar-color); width: 2px; left: 3px; }
-/* Hovered-month column highlight. */
+/* Hovered-month column highlight */
.vis-item.vis-background.month-hover-bg { background-color: var(--month-hover); }
#tooltip {
- position: fixed;
- display: none;
- background: #222;
- color: #fff;
- padding: 6px 10px;
- border-radius: 4px;
- font-size: 12px;
- pointer-events: none;
- z-index: 100;
- max-width: 320px;
- line-height: 1.4;
- white-space: pre-line;
-}
-#empty {
- padding: 40px;
- text-align: center;
- color: #999;
- font-size: 13px;
+ position: fixed; display: none; background: #222; color: #fff;
+ padding: 6px 10px; border-radius: 4px; font-size: 12px; pointer-events: none;
+ z-index: 100; max-width: 320px; line-height: 1.4; white-space: pre-line;
}
+#empty { padding: 40px; text-align: center; color: #999; font-size: 13px; }
diff --git a/frontend/src/timeline.ts b/frontend/src/timeline.ts
index 3ce91c5..5943a25 100644
--- a/frontend/src/timeline.ts
+++ b/frontend/src/timeline.ts
@@ -1,10 +1,10 @@
// Timeline view: wraps vis-timeline (groups = boards, range items = bookings).
-// Ported 1:1 from the original static/index.html logic, split into a module.
import { Timeline, DataSet } from 'vis-timeline/standalone';
import type { TimelineOptions } from 'vis-timeline/standalone';
import { escapeHtml } from './util';
import { computeCollisions } from './collisions';
-import { ARCHIVED_STATUS } from './types';
+import { colorForStatus } from './appearance';
+import type { Appearance } from './appearance';
import type { Board, Booking } from './types';
export interface RenderFlags {
@@ -21,8 +21,9 @@ export interface TimelineElements {
colResizer: HTMLElement;
}
-// Discrete zoom steps for the header slider, one per 3-month increment from 3
-// to 30 months. Index 3 (12 months) is the default on load.
+// Clicking a bar opens the corresponding Planfix task.
+const PLANFIX_TASK_URL = 'https://green-media.planfix.ru/task/';
+
export const zoomSteps: ReadonlyArray<{ days: number; label: string }> = [
{ days: 91, label: '3 месяца' },
{ days: 183, label: '6 месяцев' },
@@ -42,13 +43,19 @@ const MAX_LABEL_W = 760;
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;
+ /** Re-apply row height / centring from appearance and redraw. */
+ applyLayout(): void;
setScale(days: number): void;
}
-export function createTimelineView(el: TimelineElements): TimelineView {
+export function createTimelineView(el: TimelineElements, appearance: Appearance): TimelineView {
const groupsDS = new DataSet();
const itemsDS = new DataSet();
let lastBoards: Board[] = [];
+ let lastBookings: Booking[] = [];
+ let lastFlags: RenderFlags = { withBrand: true, withCompany: false, withCollisions: true };
const options: TimelineOptions = {
editable: false,
@@ -59,17 +66,17 @@ export function createTimelineView(el: TimelineElements): TimelineView {
verticalScroll: true,
zoomMin: zoomSteps[0]!.days * 86400000,
zoomMax: zoomSteps[zoomSteps.length - 1]!.days * 86400000,
- groupHeightMode: 'fixed',
- margin: { item: { horizontal: 2, vertical: 6 } },
+ groupHeightMode: 'auto',
+ margin: { item: { horizontal: 2, vertical: 0 } },
orientation: { axis: 'top' },
tooltip: { followMouse: false, delay: 0 },
locale: 'ru',
+ xss: { disabled: true },
};
const timeline = new Timeline(el.timelineEl, itemsDS, groupsDS, options);
- // ---- dynamic height / scroll fix: push an explicit pixel height computed
- // from the real rendered DOM, recomputed on every redraw and window resize.
+ // ---- dynamic height / scroll fix ----
let lastSetHeightPx: number | null = null;
function fitTimelineHeight(): void {
const available = el.chartWrap.clientHeight;
@@ -101,7 +108,16 @@ export function createTimelineView(el: TimelineElements): TimelineView {
window.addEventListener('resize', scheduleHeightFit);
scheduleHeightFit();
- // ---- draggable label-column width (drives --label-w; vis panel follows it).
+ // ---- clickable bars -> Planfix task ----
+ timeline.on('click', (props: any) => {
+ if (!props.item) return;
+ const item = itemsDS.get(props.item) as any;
+ if (item && item.taskId) {
+ window.open(PLANFIX_TASK_URL + encodeURIComponent(item.taskId), '_blank', 'noopener');
+ }
+ });
+
+ // ---- draggable label-column width ----
function currentLabelW(): number {
const v = getComputedStyle(document.documentElement).getPropertyValue('--label-w');
return parseInt(v, 10) || 340;
@@ -158,7 +174,7 @@ export function createTimelineView(el: TimelineElements): TimelineView {
scheduleHeightFit();
});
- // ---- tooltip following the cursor.
+ // ---- tooltip ----
let hoveredItemId: string | number | null = null;
timeline.on('itemover', (props: any) => {
hoveredItemId = props.item;
@@ -179,7 +195,7 @@ export function createTimelineView(el: TimelineElements): TimelineView {
}
});
- // ---- row hover highlight (DOM class toggling, no DataSet churn).
+ // ---- row hover highlight ----
let hoveredRowIdx = -1;
function setRowHover(idx: number): void {
if (idx === hoveredRowIdx) return;
@@ -196,7 +212,7 @@ export function createTimelineView(el: TimelineElements): TimelineView {
hoveredRowIdx = idx;
}
- // ---- hovered-month column highlight (single background item, month-snapped).
+ // ---- hovered-month column highlight ----
let lastMonthKey: string | null = null;
timeline.on('mouseMove', (props: any) => {
if (props.time) {
@@ -228,6 +244,8 @@ export function createTimelineView(el: TimelineElements): TimelineView {
function render(boards: Board[], bookings: Booking[], fit: boolean, flags: RenderFlags): void {
lastBoards = boards;
+ lastBookings = bookings;
+ lastFlags = flags;
if (boards.length === 0) {
groupsDS.clear();
itemsDS.clear();
@@ -249,7 +267,7 @@ export function createTimelineView(el: TimelineElements): TimelineView {
const items = bookings.map((bk, idx) => {
const end = new Date(bk.end_date + 'T00:00:00Z');
- end.setUTCDate(end.getUTCDate() + 1); // exclusive end so the last day shows fully
+ end.setUTCDate(end.getUTCDate() + 1);
const brand = bk.brand || '';
const company = bk.company_name || '';
@@ -258,7 +276,6 @@ export function createTimelineView(el: TimelineElements): TimelineView {
if (flags.withCompany && company) barParts.push(escapeHtml(company));
const barContent = barParts.join(' | ');
- // Tooltip: task_id / brand / company / start — end / task_status, then collisions.
const tooltipLines: string[] = [];
if (bk.task_id) tooltipLines.push(bk.task_id);
if (brand) tooltipLines.push(brand);
@@ -277,20 +294,19 @@ export function createTimelineView(el: TimelineElements): TimelineView {
}
}
- const item: any = {
+ // Colour: collision colour overrides the per-status colour.
+ const color = isCollision ? appearance.collisionColor : colorForStatus(appearance, bk.task_status);
+
+ return {
id: bk.board_id + '__' + idx,
group: bk.board_id,
start: bk.start_date,
end: end.toISOString().slice(0, 10),
- content: barContent,
+ content: `${barContent}
`,
type: 'range',
tooltipText: tooltipLines.join('\n'),
+ taskId: bk.task_id,
};
- // Colour priority: collision (purple) > archived (slate) > normal (blue).
- const isArchived = bk.task_status === ARCHIVED_STATUS;
- if (isCollision) item.className = 'collision';
- else if (isArchived) item.className = 'archived';
- return item;
});
groupsDS.clear();
@@ -303,6 +319,16 @@ export function createTimelineView(el: TimelineElements): TimelineView {
scheduleHeightFit();
}
+ function refresh(): void {
+ if (lastBoards.length) render(lastBoards, lastBookings, false, lastFlags);
+ }
+
+ function applyLayout(): void {
+ timeline.setOptions({ margin: { item: { horizontal: 2, vertical: 0 } } });
+ timeline.redraw();
+ scheduleHeightFit();
+ }
+
function setScale(days: number): void {
const range = timeline.getWindow();
const center = (range.start.getTime() + range.end.getTime()) / 2;
@@ -310,5 +336,5 @@ export function createTimelineView(el: TimelineElements): TimelineView {
timeline.setWindow(new Date(center - half), new Date(center + half));
}
- return { render, setScale };
+ return { render, refresh, applyLayout, setScale };
}