/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
js/components/TimeLinePanel.js
1 539 строк
53 KB
Starolat Sergei
fix: выделение текста в виджетах при перетаскивании за заголовок
15 июл 2026, 09:26
15 июл 2026, 09:26
0f40aca
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview TimeLinePanel - Панель выбора календарной недели (v0.4.0) * * Горизонтальный слайдер по ISO-неделям. * Трек разделён на три зоны: выбранный диапазон (от начала проекта до бегунка), * прошедшие недели (от бегунка до data_date) и будущие недели (после data_date). * Эмитит события week-input (в процессе перетаскивания) и week-select (по отпусканию). * Поддерживает фильтрацию по году через токены в заголовке. * Делегирует недельную логику в WeekIndexService (v1.0.0+). * * @version 0.4.0 * @see {Spec} specs/widgets/TimeLinePanel.md */ import dataService from "../services/DataService.js"; import weekIndexService from "../services/WeekIndexService.js"; import { filterState } from "../services/FilterStateManager.js"; import { toggleDock } from "../ui.js"; import { ArrowDown, Layout, Draggable, ChevronLeft, ChevronRight, } from "./ConstantinIcons.js"; /** * @typedef {Object} WeekInfo * @property {number} weekIndex - Глобальный weekIndex (разница в неделях от 1970-01-05) * @property {number} weekNumber - Номер ISO-недели * @property {number} year - Год ISO-недели (по воскресенью — концу недели) * @property {Date} weekStart - Понедельник 00:00 * @property {Date} weekEnd - Воскресенье 23:59:59.999 * @property {boolean} isPast - true если неделя строго до data_date */ /** * @typedef {Object} WeekSelectEventDetail * @property {number} weekIndex - Глобальный weekIndex * @property {number} weekNumber * @property {number} year * @property {string} weekStart - ISO-строка даты начала недели * @property {string} weekEnd - ISO-строка даты окончания недели * @property {boolean} isPast */ const MONTH_NAMES_GENITIVE = [ "января", "февраля", "марта", "апреля", "мая", "июня", "июля", "августа", "сентября", "октября", "ноября", "декабря", ]; class TimeLinePanel extends HTMLElement { constructor() { super(); /** @type {WeekInfo[]} */ this._weeks = []; /** @type {WeekInfo[]} */ this._visibleWeeks = []; /** @type {number[]} */ this._availableYears = []; /** @type {string} */ this._selectedYear = "all"; /** @type {number} */ this._currentWeekIndex = 0; /** @type {number} */ this._defaultWeekIndex = 0; /** @type {string|null} */ this._dataDate = null; /** @type {boolean} */ this._isMultiYear = false; /** @type {string|null} */ this._databaseId = null; /** @type {boolean} */ this._isDragging = false; /** @type {boolean} */ this._compactMode = false; /** @type {ResizeObserver|null} */ this._resizeObserver = null; /** @type {number|null} */ this._resizeDebounce = null; /** @type {number|null} */ this._tooltipRaf = null; /** @type {number|null} */ this._pendingTooltipIndex = null; /** @type {number|null} */ this._stepDebounce = null; // Bind handlers this._stopPropagation = this._stopPropagation.bind(this); this._preventDragStart = this._preventDragStart.bind(this); this._disableHostDrag = this._disableHostDrag.bind(this); this._restoreHostDrag = this._restoreHostDrag.bind(this); this._handleDatabaseReady = this._handleDatabaseReady.bind(this); this._handleDatabaseChanged = this._handleDatabaseChanged.bind(this); this._handleSliderInput = this._handleSliderInput.bind(this); this._handleSliderChange = this._handleSliderChange.bind(this); this._handleSliderDblClick = this._handleSliderDblClick.bind(this); this._handleShadowClick = this._handleShadowClick.bind(this); this._flushTooltipUpdate = this._flushTooltipUpdate.bind(this); this._handleFilterStateChange = this._handleFilterStateChange.bind(this); this._handleRequestWeekSelect = this._handleRequestWeekSelect.bind(this); this._handleDataDateChange = this._handleDataDateChange.bind(this); } connectedCallback() { if (this._disconnectRaf) { cancelAnimationFrame(this._disconnectRaf); this._disconnectRaf = null; return; } const wasRendered = this.querySelector('.timeline-panel') !== null; if (!wasRendered) { this._renderBase(); } this._setupEventListeners(); this._setupResizeObserver(); document.addEventListener( "filter-state-change", this._handleFilterStateChange, ); document.addEventListener( "data-date-change", this._handleDataDateChange, ); // Инициализируем disabled-состояние если weekRange уже активен if (filterState.isWeekRangeActive()) { this._setDisabled(true); } // Загружаем данные сразу, если база уже активна const activeDb = dataService.getActiveDatabaseId(); if (activeDb) { this._databaseId = activeDb; this.loadData(); } } disconnectedCallback() { this._disconnectRaf = requestAnimationFrame(() => { this._disconnectRaf = null; document.removeEventListener("database-ready", this._handleDatabaseReady); document.removeEventListener( "database-changed", this._handleDatabaseChanged, ); document.removeEventListener( "filter-state-change", this._handleFilterStateChange, ); document.removeEventListener( "data-date-change", this._handleDataDateChange, ); document.removeEventListener( "request-week-select", this._handleRequestWeekSelect, ); const slider = this.querySelector(".timeline-slider"); if (slider) { slider.removeEventListener("input", this._handleSliderInput); slider.removeEventListener("change", this._handleSliderChange); slider.removeEventListener("dblclick", this._handleSliderDblClick); slider.removeEventListener("mousedown", this._stopPropagation); slider.removeEventListener("pointerdown", this._stopPropagation); slider.removeEventListener("dragstart", this._preventDragStart); slider.removeEventListener("mousedown", this._disableHostDrag); slider.removeEventListener("pointerdown", this._disableHostDrag); document.removeEventListener("mouseup", this._restoreHostDrag); document.removeEventListener("pointerup", this._restoreHostDrag); } if (this._resizeObserver) { this._resizeObserver.disconnect(); this._resizeObserver = null; } if (this._tooltipRaf) { cancelAnimationFrame(this._tooltipRaf); this._tooltipRaf = null; } if (this._resizeDebounce) { clearTimeout(this._resizeDebounce); this._resizeDebounce = null; } this._weeks = []; this._visibleWeeks = []; this._availableYears = []; this._selectedYear = "all"; this._currentWeekIndex = 0; this._compactMode = false; this._dataDate = null; this.innerHTML = ""; }); } // ==================== Public API ==================== /** * Массив недель (readonly) * @returns {WeekInfo[]} */ get weeks() { return this._weeks.slice(); } /** * Текущий индекс выбранной недели * @returns {number} */ get currentWeekIndex() { return this._currentWeekIndex; } /** * Установить текущий индекс недели (восстановление из сохранённого макета). * @param {number} index */ setWeekIndex(index) { if (this._visibleWeeks.length === 0) { console.warn('[TimeLinePanel] setWeekIndex called before weeks loaded'); return; } const clamped = Math.max(0, Math.min(this._visibleWeeks.length - 1, index)); if (clamped === this._currentWeekIndex) return; this._currentWeekIndex = clamped; const slider = /** @type {HTMLInputElement} */ ( this.querySelector('.timeline-slider') ); if (slider) { slider.value = String(clamped); } this._updateTooltip(clamped); this._updateAria(clamped); this._dispatchWeekSelect(clamped); } /** * Дата актуализации (data_date) * @returns {string|null} */ get dataDate() { return this._dataDate; } /** * Асинхронная загрузка диапазона недель и data_date из БД * @returns {Promise<void>} */ async loadData() { let dbId = this.getAttribute("database-id") || this._databaseId || dataService.getActiveDatabaseId(); if (dbId === "active") { dbId = dataService.getActiveDatabaseId(); } if (!dbId) { this._showEmptyState("Нет активной базы данных"); return; } this._databaseId = dbId; try { // Делегируем построение недель в WeekIndexService await weekIndexService.buildFromDatabase(dbId); const allWeeks = weekIndexService.getAllWeeks(dbId); const meta = weekIndexService.getMeta(dbId); this._dataDate = meta.dataDate || null; if (!allWeeks.length) { this._showEmptyState("Нет данных для отображения временной шкалы"); return; } // Конвертируем строковые даты в Date для совместимости с tooltip/ARIA this._weeks = allWeeks.map((w) => ({ weekIndex: w.weekIndex, weekNumber: w.weekNumber, year: w.year, weekStart: this._parseDate(w.weekStart), weekEnd: this._parseDate(w.weekEnd), isPast: w.isPast, })); this._availableYears = [...new Set(this._weeks.map((w) => w.year))].sort( (a, b) => a - b, ); this._selectedYear = "all"; this._visibleWeeks = this._weeks.slice(); this._isMultiYear = this._visibleWeeks.length > 0 ? this._visibleWeeks[0].year !== this._visibleWeeks[this._visibleWeeks.length - 1].year : false; // Default position: last past week, or 0 if none are past const lastPastIndex = this._visibleWeeks .map((w) => w.isPast) .lastIndexOf(true); this._defaultWeekIndex = lastPastIndex >= 0 ? lastPastIndex : 0; this._currentWeekIndex = this._defaultWeekIndex; this._renderYearTokens(); this._renderSlider(); this._hideMessages(); // Автоматически сообщаем о дефолтном положении бегунка, // чтобы слушатели (например, WbsActivitiesTable) могли // пересчитать накопительные значения сразу после открытия БД. setTimeout(() => this._dispatchWeekSelect(this._currentWeekIndex), 0); } catch (err) { console.error("[TimeLinePanel] loadData failed:", err); this._showErrorState("Ошибка загрузки данных временной шкалы"); this.dispatchEvent( new CustomEvent("error", { bubbles: true, composed: true, detail: { message: err?.message || String(err) }, }), ); } } /** * Принудительный перерендер слайдера (например, при смене темы) */ render() { this._renderBase(); this._setupEventListeners(); if (this._weeks.length > 0) { this._renderYearTokens(); this._renderSlider(); } } /** * Очистка состояния компонента */ dispose() { this._weeks = []; this._visibleWeeks = []; this._availableYears = []; this._selectedYear = "all"; this._currentWeekIndex = 0; this._dataDate = null; this.innerHTML = ""; } // ==================== Private ==================== _renderBase() { this.innerHTML = ` <style>${this._getStyles()}</style> <div class="module-header"> <h3><span class="draggable-icon">${Draggable}</span>Машина времени</h3> <div class="year-tokens"></div> <div class="module-actions"> <button class="collapse-btn icon-button" title="Свернуть/Развернуть" aria-label="Свернуть или развернуть"> <span class="arrow-icon">${ArrowDown}</span> </button> <button class="dock-toggle-btn icon-button" data-target="timeline-module" title="Открепить/Закрепить" aria-label="Открепить или закрепить"> ${Layout} </button> </div> </div> <div class="timeline-panel" role="region" aria-label="Машина времени"> <div class="timeline-body"> <div class="timeline-slider-wrapper"> <input type="range" class="timeline-slider" min="0" max="0" value="0" step="1" aria-label="Выбор недели" aria-valuemin="0" aria-valuemax="0" aria-valuenow="0" aria-valuetext=""> <div class="timeline-tooltip" aria-hidden="true"> <button class="timeline-tooltip-nav timeline-tooltip-nav--left" aria-label="Предыдущая неделя">${ChevronLeft}</button> <span class="timeline-tooltip-dot"></span> <span class="timeline-tooltip-text"></span> <button class="timeline-tooltip-nav timeline-tooltip-nav--right" aria-label="Следующая неделя">${ChevronRight}</button> </div> </div> <div class="timeline-labels"> <span class="timeline-label-start"></span> <span class="timeline-label-end"></span> </div> <div class="timeline-message timeline-empty-state" style="display:none"> <span class="timeline-message-text"></span> </div> <div class="timeline-message timeline-error-state" style="display:none"> <span class="timeline-message-text"></span> </div> </div> </div> `; } _setupEventListeners() { document.addEventListener("database-ready", this._handleDatabaseReady); document.addEventListener("database-changed", this._handleDatabaseChanged); document.addEventListener("request-week-select", this._handleRequestWeekSelect); const slider = this.querySelector(".timeline-slider"); if (slider) { slider.addEventListener("input", this._handleSliderInput); slider.addEventListener("change", this._handleSliderChange); slider.addEventListener("dblclick", this._handleSliderDblClick); slider.addEventListener("mousedown", this._stopPropagation); slider.addEventListener("pointerdown", this._stopPropagation); slider.addEventListener("dragstart", this._preventDragStart); slider.addEventListener("mousedown", this._disableHostDrag); slider.addEventListener("pointerdown", this._disableHostDrag); } this.addEventListener("click", this._handleShadowClick); } /** * @param {Event} e */ _stopPropagation(e) { e.stopPropagation(); } /** * @param {DragEvent} e */ _preventDragStart(e) { e.preventDefault(); e.stopPropagation(); } _disableHostDrag() { const header = this.querySelector(".module-header") || this.querySelector("h3"); if (header && header.getAttribute("draggable") === "true") { header.setAttribute("draggable", "false"); this._dragRestored = false; document.addEventListener("mouseup", this._restoreHostDrag, { once: true, }); document.addEventListener("pointerup", this._restoreHostDrag, { once: true, }); } } _restoreHostDrag() { if (!this._dragRestored) { const header = this.querySelector(".module-header") || this.querySelector("h3"); if (header) header.setAttribute("draggable", "true"); this._dragRestored = true; } } _handleDatabaseReady(e) { const dbId = e.detail?.databaseId; if (dbId) { this._databaseId = dbId; this.loadData(); } } _handleDatabaseChanged(e) { const dbId = e.detail?.databaseId; if (dbId) { this._databaseId = dbId; this.loadData(); } } /** * Ответ на запрос текущей недели от компонентов, которые подключились * после того как TimeLinePanel уже отправил week-select. */ _handleRequestWeekSelect() { console.log('[TimeLinePanel] request-week-select received, visibleWeeks=', this._visibleWeeks.length, 'currentIndex=', this._currentWeekIndex); if (this._visibleWeeks.length > 0 && this._currentWeekIndex >= 0) { this._dispatchWeekSelect(this._currentWeekIndex); } else { console.warn('[TimeLinePanel] Cannot respond to request-week-select: no visible weeks or invalid index'); } } _handleSliderInput() { const slider = /** @type {HTMLInputElement} */ ( this.querySelector(".timeline-slider") ); const index = parseInt(slider.value, 10); this._currentWeekIndex = index; this._updateTooltipRaf(index); this._updateAria(index); this._dispatchWeekInput(index); } _updateTooltipRaf(index) { this._pendingTooltipIndex = index; if (this._tooltipRaf) return; this._tooltipRaf = requestAnimationFrame(this._flushTooltipUpdate); } _flushTooltipUpdate() { this._tooltipRaf = null; if (this._pendingTooltipIndex !== null) { this._updateTooltip(this._pendingTooltipIndex); this._pendingTooltipIndex = null; } } _handleSliderChange() { const slider = /** @type {HTMLInputElement} */ ( this.querySelector(".timeline-slider") ); const index = parseInt(slider.value, 10); this._currentWeekIndex = index; this._dispatchWeekSelect(index); } _handleShadowClick(e) { const target = /** @type {HTMLElement} */ (e.target); if (target.closest(".collapse-btn")) { this.classList.toggle("collapsed"); e.stopPropagation(); return; } if (target.closest(".dock-toggle-btn")) { toggleDock(this); e.stopPropagation(); return; } if (target.closest(".year-prev")) { this._selectYearByOffset(-1); e.stopPropagation(); return; } if (target.closest(".year-next")) { this._selectYearByOffset(1); e.stopPropagation(); return; } if (target.closest(".year-scroll-left")) { this._scrollYears(-1); e.stopPropagation(); return; } if (target.closest(".year-scroll-right")) { this._scrollYears(1); e.stopPropagation(); return; } if (target.closest(".timeline-tooltip-nav--left")) { this._stepSlider(-1); e.stopPropagation(); return; } if (target.closest(".timeline-tooltip-nav--right")) { this._stepSlider(1); e.stopPropagation(); return; } const token = target.closest(".year-token"); if (token) { const year = token.getAttribute("data-year"); if (year) { this._selectYear(year); } e.stopPropagation(); } } _handleSliderDblClick() { if (this._visibleWeeks.length === 0) return; const slider = /** @type {HTMLInputElement} */ ( this.querySelector(".timeline-slider") ); slider.value = String(this._defaultWeekIndex); this._currentWeekIndex = this._defaultWeekIndex; this._updateTooltip(this._defaultWeekIndex); this._updateAria(this._defaultWeekIndex); this._dispatchWeekInput(this._defaultWeekIndex); this._dispatchWeekSelect(this._defaultWeekIndex); } /** * @param {number} direction -1 for left, 1 for right */ _stepSlider(direction) { if (this._visibleWeeks.length === 0) return; const slider = /** @type {HTMLInputElement} */ ( this.querySelector(".timeline-slider") ); if (!slider) return; const current = parseInt(slider.value, 10); const max = parseInt(slider.max, 10); const newIndex = Math.max(0, Math.min(max, current + direction)); if (newIndex !== current) { slider.value = String(newIndex); this._currentWeekIndex = newIndex; this._updateTooltip(newIndex); this._updateAria(newIndex); this._dispatchWeekInput(newIndex); // Debounce week-select при быстром клике по стрелкам tooltip if (this._stepDebounce) clearTimeout(this._stepDebounce); this._stepDebounce = window.setTimeout(() => { this._stepDebounce = null; this._dispatchWeekSelect(newIndex); }, 80); } } _setupResizeObserver() { if (!("ResizeObserver" in window)) return; this._resizeObserver = new ResizeObserver((entries) => { if (this._resizeDebounce) clearTimeout(this._resizeDebounce); this._resizeDebounce = window.setTimeout(() => { for (const entry of entries) { const width = entry.contentRect.width; const shouldBeCompact = width < 220; if (shouldBeCompact !== this._compactMode) { this._compactMode = shouldBeCompact; this._renderYearTokens(); } this._updateTooltip(this._currentWeekIndex); window.setTimeout(() => this._updateScrollButtons(), 50); } }, 100); }); this._resizeObserver.observe(this); } /** * @param {number} offset */ _selectYearByOffset(offset) { if (!this._weeks.length) return; const options = ["all", ...this._availableYears.map(String)]; const currentIndex = options.indexOf(this._selectedYear); const newIndex = Math.max( 0, Math.min(options.length - 1, currentIndex + offset), ); if (newIndex !== currentIndex) { this._selectYear(options[newIndex]); } } _renderYearTokens() { const container = this.querySelector(".year-tokens"); if (!container) return; if (this._availableYears.length === 0) { container.innerHTML = ""; container.classList.remove("compact"); return; } if (this._compactMode) { container.classList.add("compact"); const options = ["all", ...this._availableYears.map(String)]; const currentIndex = options.indexOf(this._selectedYear); const label = this._selectedYear === "all" ? "ВСЕ" : this._selectedYear; container.innerHTML = [ `<button class="year-nav-btn year-prev" ${currentIndex <= 0 ? "disabled" : ""} aria-label="Предыдущий год">${ChevronLeft}</button>`, `<button class="year-token active" data-year="${this._selectedYear}">${label}</button>`, `<button class="year-nav-btn year-next" ${currentIndex >= options.length - 1 ? "disabled" : ""} aria-label="Следующий год">${ChevronRight}</button>`, ].join(""); } else { container.classList.remove("compact"); const allClass = this._selectedYear === "all" ? "active" : ""; const tokensHtml = [ `<button class="year-token ${allClass}" data-year="all">ВСЕ</button>`, ...this._availableYears.map((y) => { const cls = String(y) === this._selectedYear ? "active" : ""; return `<button class="year-token ${cls}" data-year="${y}">${y}</button>`; }), ].join(""); container.innerHTML = [ `<button class="year-scroll-btn year-scroll-left hidden" aria-label="Влево">${ChevronLeft}</button>`, `<div class="year-tokens-track">${tokensHtml}</div>`, `<button class="year-scroll-btn year-scroll-right hidden" aria-label="Вправо">${ChevronRight}</button>`, ].join(""); const track = container.querySelector(".year-tokens-track"); if (track) { track.onscroll = () => this._updateScrollButtons(); } this._updateScrollButtons(); } } _updateScrollButtons() { const container = this.querySelector(".year-tokens"); if (!container || container.classList.contains("compact")) return; const track = container.querySelector(".year-tokens-track"); const leftBtn = container.querySelector(".year-scroll-left"); const rightBtn = container.querySelector(".year-scroll-right"); if (!track || !leftBtn || !rightBtn || !track.lastElementChild) return; const trackWidth = track.clientWidth; const contentWidth = track.scrollWidth; const hasOverflow = contentWidth > trackWidth + 1; if (hasOverflow) { track.style.justifyContent = "flex-start"; const canScrollLeft = track.scrollLeft > 0; const canScrollRight = track.scrollLeft + track.clientWidth < track.scrollWidth - 1; leftBtn.classList.toggle("hidden", !canScrollLeft); rightBtn.classList.toggle("hidden", !canScrollRight); } else { leftBtn.classList.add("hidden"); rightBtn.classList.add("hidden"); track.style.justifyContent = "flex-end"; } } /** * @param {number} direction -1 влево, 1 вправо */ _scrollYears(direction) { const track = this.querySelector(".year-tokens-track"); if (!track) return; const step = 40; track.scrollBy({ left: direction * step, behavior: "smooth" }); setTimeout(() => this._updateScrollButtons(), 150); } /** * @param {string} year */ _selectYear(year) { if (!this._weeks.length) return; this._selectedYear = year; if (year === "all") { this._visibleWeeks = this._weeks.slice(); } else { const y = parseInt(year, 10); this._visibleWeeks = this._weeks.filter((w) => w.year === y); } this._isMultiYear = this._visibleWeeks.length > 0 ? this._visibleWeeks[0].year !== this._visibleWeeks[this._visibleWeeks.length - 1].year : false; const lastPastIndex = this._visibleWeeks .map((w) => w.isPast) .lastIndexOf(true); this._defaultWeekIndex = lastPastIndex >= 0 ? lastPastIndex : 0; this._currentWeekIndex = this._defaultWeekIndex; this._renderYearTokens(); this._renderSlider(); this._dispatchWeekSelect(this._currentWeekIndex); } _renderSlider() { const slider = /** @type {HTMLInputElement} */ ( this.querySelector(".timeline-slider") ); const wrapper = this.querySelector(".timeline-slider-wrapper"); const startLabel = this.querySelector(".timeline-label-start"); const endLabel = this.querySelector(".timeline-label-end"); if (!slider || !wrapper) return; const total = Math.max(0, this._visibleWeeks.length - 1); slider.min = "0"; slider.max = String(total); slider.value = String(this._currentWeekIndex); slider.setAttribute("aria-valuemax", String(total)); // CSS-переменные для градиента трека // Граница цветов совпадает с позицией thumb default-недели (последней прошлой) const totalSteps = Math.max(1, this._visibleWeeks.length - 1); const pastRatio = this._defaultWeekIndex / totalSteps; const selectedRatio = this._currentWeekIndex / totalSteps; const pastCount = this._visibleWeeks.filter((w) => w.isPast).length; const totalCount = this._visibleWeeks.length || 1; wrapper.style.setProperty("--past-week-count", String(pastCount)); wrapper.style.setProperty("--total-week-count", String(totalCount)); wrapper.style.setProperty( "--past-ratio", `${(pastRatio * 100).toFixed(4)}%`, ); wrapper.style.setProperty( "--selected-ratio", `${(selectedRatio * 100).toFixed(4)}%`, ); // Подписи краёв if (startLabel && this._visibleWeeks.length > 0) { const first = this._visibleWeeks[0]; startLabel.textContent = `Нед ${first.weekNumber}${this._isMultiYear ? ", " + first.year : ""}`; } if (endLabel && this._visibleWeeks.length > 0) { const last = this._visibleWeeks[this._visibleWeeks.length - 1]; endLabel.textContent = `Нед ${last.weekNumber}${this._isMultiYear ? ", " + last.year : ""}`; } // Инициализация tooltip и ARIA this._updateTooltip(this._currentWeekIndex); this._updateAria(this._currentWeekIndex); } _updateTooltip(index) { const tooltip = this.querySelector(".timeline-tooltip"); const dot = this.querySelector(".timeline-tooltip-dot"); const text = this.querySelector(".timeline-tooltip-text"); const slider = /** @type {HTMLInputElement} */ ( this.querySelector(".timeline-slider") ); const wrapper = this.querySelector(".timeline-slider-wrapper"); if (!tooltip || !text || !slider || !wrapper) return; const week = this._visibleWeeks[index]; if (!week) { tooltip.style.display = "none"; return; } tooltip.style.display = "flex"; const startFmt = this._formatDate(week.weekStart); const endFmt = this._formatDate(week.weekEnd); const yearPart = this._isMultiYear ? `, ${week.year}` : ""; text.innerHTML = `<span class="tt-line1">Неделя ${week.weekNumber}${yearPart}</span><span class="tt-line2">${startFmt} – ${endFmt}</span>`; const max = parseInt(slider.max, 10) || 0; const percent = max > 0 ? (index / max) * 100 : 0; const wrapperWidth = wrapper.clientWidth; const tooltipWidth = tooltip.offsetWidth; const thumbWidth = 14; // matches CSS thumb width const thumbCenterPx = (percent / 100) * (wrapperWidth - thumbWidth) + thumbWidth / 2; const offset = 16; const rightFits = thumbCenterPx + offset + tooltipWidth <= wrapperWidth; const leftFits = thumbCenterPx - offset - tooltipWidth >= 0; tooltip.classList.remove( "timeline-tooltip--left", "timeline-tooltip--right", ); if (rightFits) { tooltip.classList.add("timeline-tooltip--right"); tooltip.style.left = `${thumbCenterPx + offset}px`; tooltip.style.right = "auto"; } else if (leftFits) { tooltip.classList.add("timeline-tooltip--left"); tooltip.style.left = "auto"; tooltip.style.right = `${wrapperWidth - thumbCenterPx + offset}px`; } else { // По умолчанию справа tooltip.classList.add("timeline-tooltip--right"); tooltip.style.left = `${thumbCenterPx + offset}px`; tooltip.style.right = "auto"; } if (dot) { dot.className = "timeline-tooltip-dot " + (week.isPast ? "past" : "future"); } const leftBtn = this.querySelector(".timeline-tooltip-nav--left"); const rightBtn = this.querySelector(".timeline-tooltip-nav--right"); if (leftBtn) leftBtn.disabled = index <= 0; if (rightBtn) rightBtn.disabled = index >= max; } _updateAria(index) { const slider = /** @type {HTMLInputElement} */ ( this.querySelector(".timeline-slider") ); if (!slider) return; const week = this._visibleWeeks[index]; if (!week) return; slider.setAttribute("aria-valuenow", String(index)); const statusText = week.isPast ? "прошедшая" : "будущая"; const startDay = week.weekStart.getDate(); const startMonth = MONTH_NAMES_GENITIVE[week.weekStart.getMonth()]; const endDay = week.weekEnd.getDate(); const endMonth = MONTH_NAMES_GENITIVE[week.weekEnd.getMonth()]; slider.setAttribute( "aria-valuetext", `Неделя ${week.weekNumber}, ${week.year}, ${startDay} ${startMonth} — ${endDay} ${endMonth}, ${statusText}`, ); } _dispatchWeekInput(index) { const week = this._visibleWeeks[index]; if (!week) return; this.dispatchEvent( new CustomEvent("week-input", { bubbles: true, composed: true, detail: { index, weekIndex: week.weekIndex, week: this._cloneWeek(week), }, }), ); } _dispatchWeekSelect(index) { const week = this._visibleWeeks[index]; if (!week) { console.warn('[TimeLinePanel] _dispatchWeekSelect: week not found at index', index); return; } console.log('[TimeLinePanel] dispatching week-select', week.weekEnd.toISOString().split('T')[0]); /** @type {WeekSelectEventDetail} */ const detail = { weekIndex: week.weekIndex, weekNumber: week.weekNumber, year: week.year, weekStart: week.weekStart.toISOString().split("T")[0], weekEnd: week.weekEnd.toISOString().split("T")[0], isPast: week.isPast, }; this.dispatchEvent( new CustomEvent("week-select", { bubbles: true, composed: true, detail, }), ); // Сообщаем FilterStateManager о новой позиции бегунка filterState.setTimelineWeekIdx(detail.weekIndex); } /** * Обработчик изменения единого Data Date. * Переключает disabled-состояние панели в зависимости от mode. * @param {CustomEvent} e */ _handleDataDateChange(e) { const { mode } = e.detail?.dataDate || {}; this._setDisabled(mode === 'range'); } /** * Устанавливает визуальное disabled-состояние панели. * @param {boolean} isDisabled */ _setDisabled(isDisabled) { const panel = this.querySelector('.timeline-panel'); const slider = this.querySelector('.timeline-slider'); const wrapper = this.querySelector('.timeline-slider-wrapper'); if (panel) { panel.classList.toggle('disabled', isDisabled); } if (slider) { slider.disabled = isDisabled; } if (wrapper) { if (isDisabled) { wrapper.title = 'Сбросьте фильтр периода, чтобы использовать timeline'; } else { wrapper.removeAttribute('title'); } } } /** * Обработчик изменения фильтров из FilterStateManager. * При week-filter-change синхронизирует положение бегунка с endIdx диапазона. * @param {CustomEvent} e */ _handleFilterStateChange(e) { const { action, endIdx, startIdx } = e.detail || {}; if (action !== "week-filter-change") return; // Сброс диапазона — возвращаемся к дефолту if (endIdx === null && startIdx === null) { if (this._currentWeekIndex !== this._defaultWeekIndex) { this._currentWeekIndex = this._defaultWeekIndex; this._renderSlider(); this._dispatchWeekSelect(this._currentWeekIndex); } return; } // Интерфейс остаётся точечным: бегунок = конец диапазона const targetWeekIndex = endIdx !== null ? endIdx : startIdx; if (targetWeekIndex === null || !Number.isFinite(targetWeekIndex)) return; // Ищем индекс в _visibleWeeks const visibleIndex = this._visibleWeeks.findIndex( (w) => w.weekIndex === targetWeekIndex, ); if (visibleIndex >= 0 && visibleIndex !== this._currentWeekIndex) { this._currentWeekIndex = visibleIndex; this._renderSlider(); this._updateTooltip(visibleIndex); this._updateAria(visibleIndex); } } _cloneWeek(week) { return { weekIndex: week.weekIndex, weekNumber: week.weekNumber, year: week.year, weekStart: week.weekStart.toISOString().split("T")[0], weekEnd: week.weekEnd.toISOString().split("T")[0], isPast: week.isPast, }; } _parseDate(str) { if (!str) return new Date(NaN); // Поддержка YYYY-MM-DD и ISO-строк const d = new Date(str.replace(" ", "T")); if (isNaN(d.getTime())) { const parts = str.split(/[-\/.]/); if (parts.length >= 3) { return new Date( parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10), ); } } return d; } _formatDate(date) { const d = String(date.getDate()).padStart(2, "0"); const m = String(date.getMonth() + 1).padStart(2, "0"); return `${d}.${m}`; } _showEmptyState(message) { this._toggleMessage(".timeline-empty-state", message); this._toggleMessage(".timeline-error-state", null); const wrapper = this.querySelector(".timeline-slider-wrapper"); const labels = this.querySelector(".timeline-labels"); if (wrapper) wrapper.style.display = "none"; if (labels) labels.style.display = "none"; this._availableYears = []; this._selectedYear = "all"; this._renderYearTokens(); } _showErrorState(message) { this._toggleMessage(".timeline-error-state", message); this._toggleMessage(".timeline-empty-state", null); const wrapper = this.querySelector(".timeline-slider-wrapper"); const labels = this.querySelector(".timeline-labels"); if (wrapper) wrapper.style.display = "none"; if (labels) labels.style.display = "none"; this._availableYears = []; this._selectedYear = "all"; this._renderYearTokens(); } _hideMessages() { this._toggleMessage(".timeline-empty-state", null); this._toggleMessage(".timeline-error-state", null); const wrapper = this.querySelector(".timeline-slider-wrapper"); const labels = this.querySelector(".timeline-labels"); if (wrapper) wrapper.style.display = "block"; if (labels) labels.style.display = "flex"; } _toggleMessage(selector, message) { const el = this.querySelector(selector); if (!el) return; if (message) { el.style.display = "flex"; const text = el.querySelector(".timeline-message-text"); if (text) text.textContent = message; } else { el.style.display = "none"; } } _getStyles() { return ` timeline-panel { display: flex; flex-direction: column; width: 100%; --timeline-past-bg: var(--accent-color, #0078d2); --timeline-future-bg: var(--system-color, #ced8de); --timeline-selected-bg: var(--accent-hover, #0056a3); --timeline-thumb-bg: var(--accent-color, #0078d2); --timeline-thumb-border: var(--primary-bg, #fff); } timeline-panel.collapsed .timeline-body { display: none !important; } timeline-panel.collapsed > .module-header { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } .timeline-panel { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; padding: 0; box-sizing: border-box; } /* Module Header (matches main.css exactly) */ .module-header { display: flex; justify-content: flex-start; align-items: center; gap: 4px; margin-bottom: var(--space-2xs, 4px); padding-bottom: 2px; border-bottom: 1px solid var(--border-light, rgba(0,66,105,0.2)); cursor: move; min-height: 20px; overflow: hidden; } .module-header h3 { margin: 0; font-size: 10px; font-weight: var(--font-weight-semibold, 600); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-secondary, rgba(0,32,51,0.6)); line-height: 1.2; display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 0 1 auto; min-width: 0; } .draggable-icon { width: 20px; height: 20px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-tertiary, rgba(0,32,51,0.3)); flex-shrink: 0; } .draggable-icon svg { width: 100%; height: 100%; } .module-actions { display: flex; align-items: center; gap: 1px; opacity: 0; transition: opacity var(--transition, 0.2s ease); flex-shrink: 0; } .module-header:hover .module-actions { opacity: 1; } .icon-button { background: transparent; border: none; color: var(--text-tertiary, rgba(0,32,51,0.3)); cursor: pointer; padding: 2px; border-radius: var(--border-radius, 4px); display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; transition: all var(--transition, 0.2s ease); } .icon-button:hover { background: var(--surface-hover, rgba(0,66,105,0.07)); color: var(--text-primary, #002033); } .arrow-icon { display: inline-flex; width: 14px; height: 14px; transition: transform var(--transition, 0.2s ease); } .arrow-icon svg { width: 100%; height: 100%; } /* Year tokens */ .year-tokens { display: flex; align-items: center; gap: 2px; flex: 1 1 auto; min-width: 0; justify-content: flex-end; overflow: hidden; } .year-tokens.compact { flex: 0 0 auto; overflow: visible; margin-left: auto; } .year-tokens-track { display: flex; align-items: center; gap: 4px; overflow-x: auto; scroll-behavior: smooth; scrollbar-width: none; -ms-overflow-style: none; flex: 1 1 auto; min-width: 0; justify-content: flex-end; } .year-tokens-track::-webkit-scrollbar { display: none; } .year-nav-btn, .year-scroll-btn { background: transparent; border: none; color: var(--text-tertiary, rgba(0,32,51,0.3)); cursor: pointer; padding: 2px; border-radius: var(--radius-s, 4px); display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; transition: all var(--transition, 0.2s ease); flex-shrink: 0; } .year-nav-btn:hover, .year-scroll-btn:hover { background: var(--surface-hover, rgba(0,66,105,0.07)); color: var(--text-primary, #002033); } .year-nav-btn svg, .year-scroll-btn svg { width: 14px; height: 14px; } .year-nav-btn:disabled, .year-scroll-btn:disabled { opacity: 0.3; cursor: not-allowed; } .year-scroll-btn.hidden { visibility: hidden; pointer-events: none; } .year-token { background: var(--surface-hover, rgba(0,66,105,0.07)); color: var(--text-secondary, rgba(0,32,51,0.6)); border: none; border-radius: var(--radius-s, 4px); padding: 1px 5px; font-size: 10px; font-weight: var(--font-weight-medium, 500); cursor: pointer; height: 18px; line-height: 1; flex-shrink: 0; white-space: nowrap; } .year-token:hover { background: var(--border-color, rgba(0,66,105,0.25)); color: var(--text-primary, #002033); } .year-token.active { background: var(--accent-color, #0078d2); color: #fff; } /* Body */ .timeline-body { flex: 1 1 auto; display: flex; flex-direction: column; justify-content: center; min-height: 0; position: relative; contain: layout; } /* Slider */ .timeline-slider-wrapper { position: relative; width: 100%; height: 32px; display: flex; align-items: center; margin-top: var(--space-xs, 8px); --past-ratio: 0%; --selected-ratio: 0%; } .timeline-slider { -webkit-appearance: none; appearance: none; width: 100%; height: 4px; margin: 0; padding: 0; background: transparent; cursor: pointer; border-radius: 2px; outline: none; user-select: none; -webkit-user-select: none; touch-action: none; } /* Disabled state */ .timeline-panel.disabled { opacity: 0.6; } .timeline-panel.disabled .timeline-slider-wrapper { cursor: not-allowed; } .timeline-panel.disabled .timeline-slider { cursor: not-allowed; } .timeline-panel.disabled .timeline-slider::-webkit-slider-thumb { background: var(--timeline-future-bg); transform: none; } .timeline-panel.disabled .timeline-slider::-moz-range-thumb { background: var(--timeline-future-bg); transform: none; } /* WebKit track */ .timeline-slider::-webkit-slider-runnable-track { width: 100%; height: 4px; border-radius: 2px; background: linear-gradient( to right, var(--timeline-selected-bg) 0%, var(--timeline-selected-bg) var(--selected-ratio), var(--timeline-past-bg) var(--selected-ratio), var(--timeline-past-bg) var(--past-ratio), var(--timeline-future-bg) var(--past-ratio), var(--timeline-future-bg) 100% ); } .timeline-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--timeline-thumb-bg); border: 2px solid var(--timeline-thumb-border); box-shadow: 0 1px 3px rgba(0,0,0,0.25); margin-top: -5px; } .timeline-slider::-webkit-slider-thumb:hover { transform: scale(1.08); } .timeline-slider::-webkit-slider-thumb:active { transform: scale(1.15); } /* Firefox track */ .timeline-slider::-moz-range-track { width: 100%; height: 4px; border-radius: 2px; background: linear-gradient( to right, var(--timeline-selected-bg) 0%, var(--timeline-selected-bg) var(--selected-ratio), var(--timeline-past-bg) var(--selected-ratio), var(--timeline-past-bg) var(--past-ratio), var(--timeline-future-bg) var(--past-ratio), var(--timeline-future-bg) 100% ); } .timeline-slider::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: var(--timeline-thumb-bg); border: 2px solid var(--timeline-thumb-border); box-shadow: 0 1px 3px rgba(0,0,0,0.25); } .timeline-slider::-moz-range-thumb:hover { transform: scale(1.08); } .timeline-slider::-moz-range-thumb:active { transform: scale(1.15); } /* Tooltip */ .timeline-tooltip { position: absolute; top: 50%; left: 0%; transform: translateY(calc(-50% - 6px)); pointer-events: none; display: none; align-items: center; gap: 4px; padding: 3px 6px; border-radius: var(--radius-s, 4px); background: var(--surface-bg, #fff); border: 1px solid var(--border-color, rgba(0,66,105,0.25)); box-shadow: var(--shadow-sm, 0 2px 4px rgba(0,32,51,0.04)); font-size: var(--font-size-xs, 10px); color: var(--text-primary, #002033); z-index: 10; white-space: nowrap; contain: layout paint; } .timeline-tooltip-text { display: flex; flex-direction: column; line-height: 1.25; text-align: center; align-items: center; } .tt-line1 { color: var(--text-secondary, rgba(0,32,51,0.6)); } .tt-line2 { font-weight: var(--font-weight-medium, 500); } .timeline-tooltip-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } .timeline-tooltip-nav { pointer-events: auto; background: transparent; border: none; color: var(--text-tertiary, rgba(0,32,51,0.3)); cursor: pointer; padding: 0; border-radius: var(--radius-s, 4px); display: inline-flex; align-items: center; justify-content: center; width: 14px; height: 14px; transition: all var(--transition, 0.2s ease); flex-shrink: 0; } .timeline-tooltip-nav:hover { color: var(--text-primary, #002033); background: var(--surface-hover, rgba(0,66,105,0.07)); } .timeline-tooltip-nav svg { width: 12px; height: 12px; } .timeline-tooltip-nav:disabled { opacity: 0.3; cursor: not-allowed; } .timeline-tooltip-dot.past { background: var(--timeline-past-bg); } .timeline-tooltip-dot.future { background: var(--timeline-future-bg); } /* Labels */ .timeline-labels { display: flex; justify-content: space-between; margin-top: 2px; font-size: var(--font-size-2xs, 8px); color: var(--text-tertiary, rgba(0,32,51,0.3)); line-height: 1.2; } /* Messages */ .timeline-message { display: flex; align-items: center; justify-content: center; min-height: 40px; padding: var(--space-xs, 8px); font-size: var(--font-size-xs, 10px); text-align: center; border-radius: var(--radius-s, 4px); } .timeline-empty-state { color: var(--text-tertiary, rgba(0,32,51,0.3)); background: transparent; } .timeline-error-state { color: var(--error-color, #eb5757); background: var(--error-bg, rgba(235,87,87,0.12)); } `; } } customElements.define("timeline-panel", TimeLinePanel);