// 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 (lifecycle order). */ export const DEFAULT_STATUS_COLORS: Record = { 'РК. Новая': '#4f46e5', 'РК. Предварительное бронирование': '#4f46e5', 'РК. Макет РИМ согласован': '#4f46e5', 'РК. Размещено': '#4f46e5', 'РК. Ожидает монтаж': '#4f46e5', 'РК. Ожидает демонтаж': '#4f46e5', 'РК. Ожидает перемонтаж': '#4f46e5', 'РК. Архив': '#4f46e5', }; /** Display name for a status: strip the systemic "РК. " prefix. */ export function statusLabel(status: string): string { return status.replace(/^РК\.\s+/, ''); } export function defaultAppearance(): Appearance { return { colors: { ...DEFAULT_STATUS_COLORS }, collisionColor: '#e8590c', defaultColor: '#4f46e5', 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; } // Circular two-arrows "reset" glyph (Feather refresh-cw). const RESET_ICON = ''; /** * A status colour row: clean status name, a grey reset glyph that restores this * status's default colour, then the colour picker. */ function statusColorRow(status: string, value: string, defColor: string, onInput: (v: string) => void): HTMLElement { const row = document.createElement('div'); row.className = 'decor-row'; const span = document.createElement('span'); span.textContent = statusLabel(status); const wrap = document.createElement('div'); wrap.className = 'decor-color-wrap'; const reset = document.createElement('button'); reset.type = 'button'; reset.className = 'decor-color-reset'; reset.title = 'Сбросить цвет статуса'; reset.setAttribute('aria-label', 'Сбросить цвет статуса'); reset.innerHTML = RESET_ICON; const input = document.createElement('input'); input.type = 'color'; input.value = value; input.addEventListener('input', () => onInput(input.value)); reset.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); input.value = defColor; onInput(defColor); }); wrap.appendChild(reset); wrap.appendChild(input); row.appendChild(span); row.appendChild(wrap); 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( statusColorRow(st, colorForStatus(a, st), DEFAULT_STATUS_COLORS[st] || a.defaultColor, (v) => { a.colors[st] = v; saveAppearance(a); onChange(); }), ); } colours.appendChild( colorRow('Коллизия', a.collisionColor, (v) => { a.collisionColor = 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); }