182 lines
8.8 KiB
JavaScript
182 lines
8.8 KiB
JavaScript
// Общая "геометрия" таймлайна: раскладка по колонкам при пересечениях, расчёт
|
||
// видимого диапазона и каскадный сдвиг соседних блоков. Не привязана к конкретной
|
||
// странице - работает в отвлечённых минутах (0..1440), а не в ISO-датах:
|
||
// TodaySchedule.jsx переводит plannedStart/plannedEnd в минуты дня перед вызовом,
|
||
// BlockTemplate.jsx - startOffsetMin (RELATIVE) или fixedTime (FIXED) относительно
|
||
// опорного времени пробуждения, которое используется только для отображения
|
||
// макета (см. комментарий в BlockTemplate.jsx).
|
||
|
||
export const BLOCK_OUTER_GAP = 8
|
||
export const BLOCK_COL_GAP = 4
|
||
|
||
export function formatHHmm(min) {
|
||
const wrapped = ((Math.round(min) % 1440) + 1440) % 1440
|
||
const h = String(Math.floor(wrapped / 60)).padStart(2, '0')
|
||
const m = String(wrapped % 60).padStart(2, '0')
|
||
return `${h}:${m}`
|
||
}
|
||
|
||
// Раскладка блоков по колонкам при пересечении по времени - см. историю алгоритма
|
||
// (итерации 14-15 в project memory): два прохода - сначала sweep по времени начала
|
||
// находит границы кластеров пересечений, затем внутри каждого кластера участники
|
||
// сортируются по убыванию длительности (самый длинный - в крайнюю левую колонку) и
|
||
// раскладываются first-fit.
|
||
export function layoutColumns(items) {
|
||
const result = new Map()
|
||
const sorted = [...items].sort((a, b) => a.start - b.start)
|
||
|
||
let active = []
|
||
let cluster = []
|
||
const clusters = []
|
||
|
||
for (const it of sorted) {
|
||
const start = it.start
|
||
const end = Math.max(start, it.end)
|
||
active = active.filter((a) => a.end > start)
|
||
if (active.length === 0 && cluster.length > 0) {
|
||
clusters.push(cluster)
|
||
cluster = []
|
||
}
|
||
const member = { id: it.id, start, end }
|
||
active.push(member)
|
||
cluster.push(member)
|
||
}
|
||
if (cluster.length > 0) clusters.push(cluster)
|
||
|
||
for (const members of clusters) {
|
||
const byDuration = [...members].sort((a, b) => {
|
||
const durA = a.end - a.start
|
||
const durB = b.end - b.start
|
||
if (durB !== durA) return durB - durA
|
||
return a.start - b.start
|
||
})
|
||
const assigned = []
|
||
let clusterCols = 0
|
||
for (const m of byDuration) {
|
||
const usedCols = new Set(assigned.filter((a) => a.start < m.end && a.end > m.start).map((a) => a.col))
|
||
let col = 0
|
||
while (usedCols.has(col)) col++
|
||
assigned.push({ ...m, col })
|
||
clusterCols = Math.max(clusterCols, col + 1)
|
||
}
|
||
for (const a of assigned) {
|
||
result.set(a.id, { col: a.col, cols: clusterCols })
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
export function columnStyle(col, cols) {
|
||
if (cols <= 1) {
|
||
return { left: `${BLOCK_OUTER_GAP}px`, right: `${BLOCK_OUTER_GAP}px` }
|
||
}
|
||
const totalGaps = (cols - 1) * BLOCK_COL_GAP
|
||
const available = `(100% - ${2 * BLOCK_OUTER_GAP}px - ${totalGaps}px)`
|
||
const leftPx = BLOCK_OUTER_GAP + col * BLOCK_COL_GAP
|
||
return {
|
||
left: `calc(${leftPx}px + ${col} * ${available} / ${cols})`,
|
||
width: `calc(${available} / ${cols})`
|
||
}
|
||
}
|
||
|
||
// Диапазон видимой части таймлайна - вынесено из TodaySchedule.jsx без изменения
|
||
// логики (округление до получаса + минимум 4 часа на экране), только обобщено на
|
||
// { start, end } вместо ISO-полей occurrence'а.
|
||
export function computeRange(items, { fallbackStart = 6 * 60, fallbackEnd = 23 * 60, paddingMin = 30, minSpanMin = 4 * 60 } = {}) {
|
||
if (items.length === 0) {
|
||
return { rangeStart: fallbackStart, rangeEnd: fallbackEnd }
|
||
}
|
||
let min = Math.min(...items.map((i) => i.start))
|
||
let max = Math.max(...items.map((i) => i.end))
|
||
min = Math.max(0, Math.floor((min - paddingMin) / 30) * 30)
|
||
max = Math.min(24 * 60, Math.ceil((max + paddingMin) / 30) * 30)
|
||
if (max - min < minSpanMin) {
|
||
max = Math.min(24 * 60, min + minSpanMin)
|
||
}
|
||
return { rangeStart: min, rangeEnd: max }
|
||
}
|
||
|
||
/**
|
||
* Каскадный пересчёт соседних блоков при перетаскивании/резайзе одного из них.
|
||
*
|
||
* items - массив { id, start, end, locked }, ОБЯЗАТЕЛЬНО в хронологическом порядке
|
||
* (тот же порядок, в котором блоки идут по факту в расписании/шаблоне). locked=true -
|
||
* FIXED-блок (или неактивный блок шаблона): сам никогда не двигается и не
|
||
* растягивается ("в этом движении не участвует"), но цепочка идёт СКВОЗЬ него к
|
||
* следующим блокам - как и на бэкенде в DailyScheduleService.shift().
|
||
*
|
||
* id/mode/proposedStart/proposedEnd - то, что предлагает пользователь драгом
|
||
* (mode: 'move' | 'resize-start' | 'resize-end').
|
||
*
|
||
* cascadeEnabled=false (Shift при драге, либо тумблер "только этот блок") -
|
||
* двигается только сам блок; вместо каскада применяются мягкие ограничения, чтобы
|
||
* не наехать на соседние блоки (без каскада их никто не подвинет).
|
||
*
|
||
* Жёсткое правило (действует всегда, каскад тут ни при чём): начало блока нельзя
|
||
* сдвинуть раньше начала предыдущего блока - иначе у предыдущего получится
|
||
* отрицательная длительность.
|
||
*
|
||
* Возвращает Map<id, {start, end}> - только для блоков, у которых что-то реально
|
||
* изменилось (включая сам редактируемый блок).
|
||
*/
|
||
export function computeCascade(items, id, mode, proposedStart, proposedEnd, { cascadeEnabled = true } = {}) {
|
||
const index = items.findIndex((it) => it.id === id)
|
||
if (index === -1) return new Map()
|
||
|
||
const current = items[index]
|
||
const prev = index > 0 ? items[index - 1] : null
|
||
const next = index < items.length - 1 ? items[index + 1] : null
|
||
const startChanged = mode !== 'resize-end'
|
||
const endChanged = mode !== 'resize-start'
|
||
|
||
let start = proposedStart
|
||
let end = proposedEnd
|
||
|
||
if (startChanged && prev && !prev.locked) {
|
||
// FIXED-блоки НЕ считаются "стеной" для редактируемого блока - с ними
|
||
// разрешено визуально пересекаться (раскладка по колонкам), поэтому
|
||
// ограничение по предыдущему блоку действует только когда сам предыдущий
|
||
// блок не locked. Без каскада предыдущий (не locked) блок никто не
|
||
// подвинет/не растянет - значит, нельзя залезать поверх его занятого
|
||
// времени. С каскадом ограничение мягче: только не раньше НАЧАЛА
|
||
// предыдущего блока - его конец мы всё равно сейчас подтянем следующим
|
||
// шагом.
|
||
const prevIsAnchor = !cascadeEnabled
|
||
const floor = prevIsAnchor ? prev.end : prev.start
|
||
if (start < floor) {
|
||
const delta = floor - start
|
||
start += delta
|
||
if (mode === 'move') end += delta
|
||
}
|
||
}
|
||
|
||
const result = new Map()
|
||
result.set(id, { start, end })
|
||
|
||
if (startChanged && prev && !prev.locked && cascadeEnabled && prev.end !== start) {
|
||
result.set(prev.id, { start: prev.start, end: start })
|
||
}
|
||
|
||
if (endChanged) {
|
||
const delta = end - current.end
|
||
if (cascadeEnabled) {
|
||
if (delta !== 0) {
|
||
for (let i = index + 1; i < items.length; i++) {
|
||
const it = items[i]
|
||
if (it.locked) continue
|
||
result.set(it.id, { start: it.start + delta, end: it.end + delta })
|
||
}
|
||
}
|
||
} else if (next && !next.locked && end > next.start) {
|
||
// Без каскада не подвигаем следующий блок, но и не даём на него залезть -
|
||
// если только следующий блок сам не FIXED: с FIXED пересечение допустимо
|
||
// (раскладка по колонкам), он тут не "стена".
|
||
end = next.start
|
||
result.set(id, { start, end })
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|