Files
trip-plan/public/js/views/stayBands.js
T
grabowski b066e7b885 Add drag & drop for entries and per-stay band colours
Calendar chips can be dragged onto another in-range day (multi-day entries shift end_date by the same delta in one PATCH); day-editor rows get a drag handle to reorder within a day, patching sort_order only for changed positions. Continues/dropoff ghost chips and stay bands are not draggable; touch devices fall back to the existing click/edit flow. Each stay band now gets a deterministic colour from an 8-hue palette by chronological index, mirrored as dots in the summary Areas list.
2026-07-19 02:09:26 +07:00

108 lines
4.2 KiB
JavaScript

// Stay "area" bands for the calendar. Stay entries render as continuous
// coloured bars spanning date..end_date across each week row (all-day-event
// style) instead of day chips. Lanes are assigned globally (interval
// partitioning) so a stay keeps the same vertical position across weeks.
import { el } from '../dom.js';
import { parseYMD, daysBetweenInclusive, typeInfo, stayShortName, entrySpanDays } from '../format.js';
// Per-instance band palette: each stay gets its own hue (not the generic stay
// type colour) so overlapping/adjacent areas read apart. Muted-but-distinct
// tones that harmonise with the entry-type palette and keep #334155 text
// readable on their color-mix(…20%, white) band backgrounds. Assignment is
// deterministic — index in the chronologically-sorted stays list % 8 — so a
// stay keeps its colour across re-renders, week rows, and the Areas list.
export const STAY_PALETTE = [
'#f59e0b', // amber
'#14b8a6', // teal
'#8b5cf6', // violet
'#f43f5e', // rose
'#0ea5e9', // sky
'#65a30d', // lime
'#f97316', // orange
'#6366f1', // indigo
];
// Deterministic colour for the Nth stay in chronological order.
export function stayColor(index) {
return STAY_PALETTE[((index % STAY_PALETTE.length) + STAY_PALETTE.length) % STAY_PALETTE.length];
}
// Returns { stays: [{entry,start,end,name,days,lane,color}], laneCount }.
export function computeStayLayout(entries) {
const stays = entries
.filter((e) => e.type === 'stay')
.map((e) => ({
entry: e,
start: e.date,
end: e.end_date && e.end_date >= e.date ? e.end_date : e.date,
name: stayShortName(e),
days: entrySpanDays(e),
lane: 0,
color: null,
}))
.sort((a, b) => (a.start < b.start ? -1 : a.start > b.start ? 1 : a.entry.id - b.entry.id));
// Colour by chronological index (same ordering summary.areas uses), assigned
// here next to lanes so every consumer of the layout shares one source.
stays.forEach((s, i) => { s.color = stayColor(i); });
const laneEnds = []; // last end date (ymd) occupying each lane
for (const s of stays) {
// A lane is free if its last stay ended strictly before this one starts.
let lane = laneEnds.findIndex((end) => end < s.start);
if (lane === -1) {
lane = laneEnds.length;
laneEnds.push(s.end);
} else {
laneEnds[lane] = s.end;
}
s.lane = lane;
}
return { stays, laneCount: laneEnds.length };
}
// Bands strip for one week (7 ymd strings). Returns null if no stay intersects.
export function renderWeekBands(weekYmd, layout, tctx) {
const weekStart = weekYmd[0];
const weekEnd = weekYmd[6];
const inWeek = layout.stays.filter((s) => s.start <= weekEnd && s.end >= weekStart);
if (!inWeek.length) return null;
const maxLane = inWeek.reduce((m, s) => Math.max(m, s.lane), 0);
const strip = el('div', {
class: 'cal-week-bands',
style: { gridTemplateRows: `repeat(${maxLane + 1}, var(--band-h))` },
});
for (const s of inWeek) {
const segStart = s.start > weekStart ? s.start : weekStart;
const segEnd = s.end < weekEnd ? s.end : weekEnd;
const col = dayIndex(weekStart, segStart); // 0..6
const span = daysBetweenInclusive(segStart, segEnd);
const roundLeft = segStart === s.start;
const roundRight = segEnd === s.end;
const info = typeInfo('stay');
const band = el('div', {
class: `cal-band${roundLeft ? ' round-l' : ''}${roundRight ? ' round-r' : ''}`,
style: { gridColumn: `${col + 1} / span ${span}`, gridRow: String(s.lane + 1), '--chip': s.color },
role: 'button',
tabindex: '0',
title: `${info.icon} ${s.name} · ${s.days} ${s.days === 1 ? 'day' : 'days'}`,
},
el('span', { class: 'cal-band-label' }, `${info.icon} ${s.name} · ${s.days} ${s.days === 1 ? 'day' : 'days'}`));
const open = (e) => { e.stopPropagation(); tctx.openDay(s.entry.date); };
band.addEventListener('click', open);
band.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(e); }
});
strip.appendChild(band);
}
return strip;
}
function dayIndex(weekStart, day) {
return Math.round((parseYMD(day) - parseYMD(weekStart)) / 86400000);
}