Колонка Город/Адрес/Код + оформление шкалы времени
1) Первая колонка графика: в углу — кнопки «Показывать» Город/Адрес/Код (стиль пилюль, активная — фиолетовая). Строка собирается из включённых сущностей, разделённых кружком 7px; код без скобок; адрес тёмный, город/код приглушённые. «Адрес» (или последний активный) нельзя снять — строка не пустеет. Доступно всем ролям, выбор в localStorage. Лёгкое обновление подписей (view.refreshLabels — без пересборки баров). 2) Временная шкала: единые 3-буквенные месяцы (format.minorLabels/majorLabels), год индиго и жирнее (.vis-text.vis-major), подсветка текущего месяца (фон-элемент cur-month-bg). Красная линия «сегодня» без изменений. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3939dbf8f5
commit
17b70b1f44
@ -164,6 +164,12 @@
|
||||
<div id="pane-timeline">
|
||||
<div id="chart-wrap">
|
||||
<div id="timeline"></div>
|
||||
<div id="col-info">
|
||||
<span class="ci-label">Показывать</span>
|
||||
<button type="button" class="ci-toggle" id="ci-city" data-part="city">Город</button>
|
||||
<button type="button" class="ci-toggle is-on" id="ci-address" data-part="address">Адрес</button>
|
||||
<button type="button" class="ci-toggle is-on" id="ci-code" data-part="code">Код</button>
|
||||
</div>
|
||||
<div class="field zoom-field" id="zoom-field">
|
||||
<span id="zoom-collapsed" class="zoom-collapsed">12М</span>
|
||||
<div class="zoom-full">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "mapdash-frontend",
|
||||
"private": true,
|
||||
"version": "0.2.17",
|
||||
"version": "1.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -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<HTMLElement>('.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.
|
||||
|
||||
@ -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. */
|
||||
|
||||
@ -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 = '<span class="ci-dot" aria-hidden="true"></span>';
|
||||
const parts: string[] = [];
|
||||
if (flags.showCity && b.board_city) parts.push(`<span class="ci-city">${escapeHtml(b.board_city)}</span>`);
|
||||
if (flags.showAddress && b.board_address) parts.push(`<span class="ci-addr">${escapeHtml(b.board_address)}</span>`);
|
||||
if (flags.showCode && b.board_id) parts.push(`<span class="ci-code">${escapeHtml(b.board_id)}</span>`);
|
||||
if (!parts.length) parts.push(`<span class="ci-addr">${escapeHtml(b.board_address)}</span>`);
|
||||
return `<span class="ci-cell">${parts.join(dot)}</span>`;
|
||||
}
|
||||
|
||||
// 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<any>();
|
||||
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<HTMLElement>('.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; },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user