frontend UI: header 4 groups, Оформление menu (colors/sizes/fonts), centered bars via inner div, clickable Planfix links

This commit is contained in:
aaverbitskiy 2026-07-17 19:27:22 +00:00
parent 2d66871b62
commit b393562c3a
5 changed files with 490 additions and 220 deletions

View File

@ -8,62 +8,67 @@
<body>
<header>
<div class="brand">
<div class="brand-name">Green Media</div>
<div class="brand-sub">Адресная программа</div>
</div>
<div class="field">
<label>Город</label>
<div class="dropdown" id="city-dropdown">
<button type="button" class="dropdown-btn" id="city-btn">
<span id="city-btn-text">Все города</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="city-panel"></div>
<!-- Group 1: brand -->
<div class="hgroup hgroup-brand">
<div class="brand">
<div class="brand-name">Green Media</div>
<div class="brand-sub">Адресная программа</div>
</div>
</div>
<div class="field">
<label>Размер</label>
<div class="dropdown" id="dimension-dropdown">
<button type="button" class="dropdown-btn" id="dimension-btn">
<span id="dimension-btn-text">Все размеры</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="dimension-panel"></div>
<!-- Group 2: fields -->
<div class="hgroup">
<div class="field">
<label>Город</label>
<div class="dropdown" id="city-dropdown">
<button type="button" class="dropdown-btn" id="city-btn">
<span id="city-btn-text">Все города</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="city-panel"></div>
</div>
</div>
<div class="field">
<label>Размер</label>
<div class="dropdown" id="dimension-dropdown">
<button type="button" class="dropdown-btn" id="dimension-btn">
<span id="dimension-btn-text">Все размеры</span>
<span class="caret"></span>
</button>
<div class="dropdown-panel" id="dimension-panel"></div>
</div>
</div>
<div class="field">
<label for="search">Поиск (адрес / код)</label>
<input type="text" id="search" placeholder="например, Азина" />
</div>
</div>
<div class="field">
<label for="search">Поиск (адрес / код)</label>
<input type="text" id="search" placeholder="например, Азина" />
<!-- Group 3: checkboxes -->
<div class="hgroup">
<div class="field checkbox-field">
<label for="show-brand"><input type="checkbox" id="show-brand" checked /> Бренд</label>
</div>
<div class="field checkbox-field">
<label for="show-company"><input type="checkbox" id="show-company" /> Компания</label>
</div>
<div class="field checkbox-field">
<label for="show-collisions"><input type="checkbox" id="show-collisions" checked /> Коллизии размещения</label>
</div>
</div>
<div class="field checkbox-field">
<label for="show-brand">
<input type="checkbox" id="show-brand" checked />
Бренд
</label>
</div>
<div class="field checkbox-field">
<label for="show-company">
<input type="checkbox" id="show-company" />
Компания
</label>
</div>
<div class="field checkbox-field">
<label for="show-collisions">
<input type="checkbox" id="show-collisions" checked />
Коллизии размещения
</label>
</div>
<div class="field zoom-field">
<label for="zoom-slider">Масштаб: <span id="zoom-label">12 месяцев</span></label>
<input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" />
<!-- Group 4: appearance settings -->
<div class="hgroup">
<div class="field zoom-field">
<label for="zoom-slider">Масштаб: <span id="zoom-label">12 месяцев</span></label>
<input type="range" id="zoom-slider" min="0" max="9" step="1" value="3" />
</div>
<div class="decor-wrap">
<button type="button" class="decor-btn" id="decor-btn">Оформление ▾</button>
<div class="decor-menu" id="decor-menu"></div>
</div>
</div>
<div id="status"></div>

229
frontend/src/appearance.ts Normal file
View File

@ -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<string, string>;
/** 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<string, string> = {
'РК. Новая': '#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<Appearance>;
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);
}

View File

@ -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<void> {
const meta = await api.meta();
cityDropdown.setValues(meta.cities, 'Все города');
@ -64,6 +96,7 @@ async function loadData(fit: boolean): Promise<void> {
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<void> {
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;

View File

@ -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; }

View File

@ -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<any>();
const itemsDS = new DataSet<any>();
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: `<div class="bar-inner" style="background-color:${color};">${barContent}</div>`,
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 };
}