Files
scheduller-service/frontend/src/scheduleGeometry.js
T
Kayashov.SM 46a5cebd54
build-and-deploy / build-and-deploy (push) Successful in 20m21s
front: исправлен баг перемещения блоков
2026-09-08 16:05:42 +04:00

182 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Общая "геометрия" таймлайна: раскладка по колонкам при пересечениях, расчёт
// видимого диапазона и каскадный сдвиг соседних блоков. Не привязана к конкретной
// странице - работает в отвлечённых минутах (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
}