/
Starolat
/
DeepDive
Обзор
Документация
Войти
/
Starolat
/
DeepDive
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
js/components/ActivityChainPanel.js
1 047 строк
37 KB
Starolat Sergei
fix: выделение текста в виджетах при перетаскивании за заголовок
15 июл 2026, 09:26
15 июл 2026, 09:26
0f40aca
Код
Авторство
О чём код?
// @ts-check /** * @fileoverview ActivityChainPanel — Панель «Цепочка работ» (COMP-022) * @version 1.0.0 * * Визуализирует цепочку ведущих (driving) предшественников для выделенной работы. * Встраивается в dockable layout, использует CSS-переменные Consta Compact. * * @see specs/widgets/ActivityChainPanel.md */ import dataService from '../services/DataService.js'; import dataIndexer from '../services/DataIndexer.js'; import { Draggable, ArrowDown, Layout, GitBranch, Loader } from './ConstantinIcons.js'; /** * @typedef {import('../types.js').ActivityChainItem} ActivityChainItem */ class ActivityChainPanel extends HTMLElement { constructor() { super(); /** @type {ActivityChainItem[]} */ this._chain = []; /** @type {boolean} */ this._isLoading = false; /** @type {number|null} */ this._selectedActivityId = null; /** @type {'current'|'target'} */ this._scheduleType = 'current'; /** @type {number} */ this._maxChainLength = 50; /** @type {string|null} */ this._databaseId = null; /** @type {string|null} */ this._currentDbId = null; /** @type {number|null} */ this._focusedIndex = null; // Bound handlers for correct removeEventListener this._onSelectionChange = this._onSelectionChange.bind(this); this._onDatabaseReady = this._onDatabaseReady.bind(this); this._onDatabaseRemoved = this._onDatabaseRemoved.bind(this); this._onKeyDown = this._onKeyDown.bind(this); this._onItemClick = this._onItemClick.bind(this); this._disableHostDrag = this._disableHostDrag.bind(this); this._restoreHostDrag = this._restoreHostDrag.bind(this); } // ==================== Lifecycle ==================== connectedCallback() { const wasRendered = this.querySelector('.chain-panel-body') !== null; if (!wasRendered) { this._render(); } document.addEventListener('table-selection-change', this._onSelectionChange); document.addEventListener('database-ready', this._onDatabaseReady); document.addEventListener('database-removed', this._onDatabaseRemoved); this.addEventListener('keydown', this._onKeyDown); this.addEventListener('mousedown', this._disableHostDrag); this.addEventListener('pointerdown', this._disableHostDrag); // If DB already active const activeDb = dataService.getActiveDatabaseId(); if (activeDb) { this._currentDbId = activeDb; } } disconnectedCallback() { document.removeEventListener('table-selection-change', this._onSelectionChange); document.removeEventListener('database-ready', this._onDatabaseReady); document.removeEventListener('database-removed', this._onDatabaseRemoved); this.removeEventListener('keydown', this._onKeyDown); this.removeEventListener('mousedown', this._disableHostDrag); this.removeEventListener('pointerdown', this._restoreHostDrag); } // ==================== Attributes ==================== static get observedAttributes() { return ['database-id', 'schedule-type']; } attributeChangedCallback(name, oldValue, newValue) { if (oldValue === newValue) return; if (name === 'database-id') { this._databaseId = newValue; this._currentDbId = newValue || dataService.getActiveDatabaseId(); } if (name === 'schedule-type') { this._scheduleType = newValue === 'target' ? 'target' : 'current'; if (this._selectedActivityId !== null) { this.refresh(); } } } // ==================== Public API ==================== get selectedActivityId() { return this._selectedActivityId; } get chain() { return [...this._chain]; } get isLoading() { return this._isLoading; } get isCollapsed() { return this.classList.contains('collapsed'); } get maxChainLength() { return this._maxChainLength; } set maxChainLength(value) { this._maxChainLength = Math.max(1, Math.min(100, Number(value) || 50)); } /** * Установить выделенную работу и перестроить цепочку * @param {number} id * @param {string} [scheduleType] * @returns {Promise<void>} */ async setSelectedActivity(id, scheduleType) { if (scheduleType) { this._scheduleType = scheduleType === 'target' ? 'target' : 'current'; } this._selectedActivityId = id; await this._buildAndRender(); } /** * Перезагрузить цепочку для текущей выделенной работы * @returns {Promise<void>} */ async refresh() { if (this._selectedActivityId === null) { this._showWaitingState(); return; } await this._buildAndRender(); } /** * Очистить цепочку и сбросить выделение */ clear() { this._selectedActivityId = null; this._chain = []; this._showWaitingState(); } expand() { this.classList.remove('collapsed'); const arrow = this.querySelector('.arrow-icon'); if (arrow) arrow.classList.remove('collapsed'); } collapse() { this.classList.add('collapsed'); const arrow = this.querySelector('.arrow-icon'); if (arrow) arrow.classList.add('collapsed'); } // ==================== Rendering ==================== _render() { this.innerHTML = ` <style> activity-chain-panel, activity-chain-panel.module { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; overflow: hidden; } .chain-panel-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--space-xs, 8px); } .chain-list { display: flex; flex-direction: column; gap: 0; list-style: none; margin: 0; padding: 0; } .chain-item { display: flex; align-items: stretch; gap: var(--space-xs, 8px); padding: var(--space-2xs, 4px) 0; position: relative; cursor: pointer; transition: background-color var(--transition-fast); border-radius: var(--radius-s, 4px); outline: none; } .chain-item:hover { background: var(--color-bg-ghost, rgba(0,0,0,0.04)); } .chain-item:focus-visible { outline: 2px solid var(--color-control-bg-primary); outline-offset: -2px; } .chain-marker-column { display: flex; flex-direction: column; align-items: center; width: 16px; flex-shrink: 0; position: relative; padding-top: 2px; } .chain-marker { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; z-index: 1; background: var(--color-typo-ghost); border: 2px solid var(--color-typo-ghost); } .chain-marker--root { background: var(--color-typo-ghost); border-color: var(--color-typo-ghost); } .chain-marker--selected { background: var(--color-control-bg-primary, #0071b2); border-color: var(--color-control-bg-primary, #0071b2); } .chain-connector { position: absolute; top: 7px; bottom: -15px; left: 50%; width: 0; border-left: 2px solid var(--color-bg-border); transform: translateX(-50%); z-index: 0; } .chain-item:last-child .chain-connector { display: none; } .chain-content { display: flex; flex-direction: column; gap: var(--space-3xs, 2px); min-width: 0; flex: 1; padding-bottom: var(--space-2xs, 4px); } .chain-code { font-size: var(--font-size-xs, 10px); font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--color-typo-primary); line-height: var(--line-height-2xs, 1.1em); } .chain-name { font-size: var(--font-size-sm, 11px); color: var(--color-typo-secondary); line-height: var(--line-height-sm, 1.4em); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .chain-dates { font-size: var(--font-size-xs, 10px); color: var(--color-typo-ghost); line-height: var(--line-height-2xs, 1.1em); } .chain-empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: var(--space-md, 16px) var(--space-xs, 8px); gap: var(--space-xs, 8px); text-align: center; color: var(--color-typo-ghost); font-size: var(--font-size-xs, 10px); } .chain-loading { display: flex; align-items: center; justify-content: center; padding: var(--space-md, 16px); color: var(--color-typo-ghost); gap: var(--space-xs, 8px); font-size: var(--font-size-xs, 10px); } .chain-loading svg { animation: spin 1s linear infinite; width: 16px; height: 16px; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .chain-aria-live { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .chain-tooltip-overlay { position: fixed; z-index: 1000; pointer-events: none; top: 0; left: 0; } .chain-tooltip-content { background: var(--color-bg-default, #fff); border: 1px solid var(--color-bg-border); border-radius: var(--radius-s, 4px); padding: var(--space-xs, 8px); box-shadow: var(--shadow-layer, 0 4px 12px rgba(0,0,0,0.15)); font-size: var(--font-size-xs, 10px); line-height: 1.4; color: var(--color-typo-primary); max-width: 280px; word-break: break-word; } .chain-tooltip-name { font-weight: 600; margin-bottom: 4px; color: var(--color-typo-primary); } .chain-tooltip-row { margin-top: 2px; color: var(--color-typo-secondary); } .chain-tooltip-label { color: var(--color-typo-ghost); font-weight: 500; } </style> <div class="module-header"> <h3> <span class="draggable-icon" style="display: inline-flex; width: 20px; height: 20px; vertical-align: middle; margin-right: 2px; color: var(--text-tertiary);">${Draggable}</span> ЦЕПОЧКА РАБОТ </h3> <div class="module-actions"> <button class="collapse-btn icon-button" title="Свернуть/Развернуть"> <span class="arrow-icon">${ArrowDown}</span> </button> <button class="dock-toggle-btn icon-button" data-target="activity-chain-panel" title="Открепить/Закрепить"> ${Layout} </button> </div> </div> <div class="chain-panel-body" role="region" aria-label="Цепочка ведущих предшественников"> <div class="chain-content-area"></div> <div class="chain-aria-live" aria-live="polite" aria-atomic="true"></div> </div> <div class="chain-tooltip-overlay" style="display:none;"> <div class="chain-tooltip-content"></div> </div> `; this._showWaitingState(); } _getContentArea() { return this.querySelector('.chain-content-area'); } _getAriaLive() { return this.querySelector('.chain-aria-live'); } _showWaitingState() { const area = this._getContentArea(); if (!area) return; area.innerHTML = ` <div class="chain-empty-state"> <span style="display: inline-flex; width: 24px; height: 24px;">${GitBranch}</span> <span>Выберите работу в таблице,<br>чтобы увидеть цепочку предшественников</span> </div> `; this._chain = []; this._isLoading = false; } _showLoadingState() { const area = this._getContentArea(); if (!area) return; area.innerHTML = ` <div class="chain-loading"> <span style="display: inline-flex; width: 16px; height: 16px;">${Loader}</span> <span>Загрузка цепочки…</span> </div> `; this._isLoading = true; } _showEmptyState(reason) { const area = this._getContentArea(); if (!area) return; let message = 'Нет ведущих предшественников'; if (reason === 'completed') message = 'Работа завершена'; if (reason === 'no-predecessors') message = 'У работы нет предшественников'; if (reason === 'cycle-detected') message = 'Обнаружен цикл в связях'; area.innerHTML = ` <div class="chain-empty-state"> <span style="display: inline-flex; width: 24px; height: 24px;">${GitBranch}</span> <span>${message}</span> </div> `; this._isLoading = false; } _showErrorState(message) { const area = this._getContentArea(); if (!area) return; area.innerHTML = ` <div class="chain-empty-state"> <span style="display: inline-flex; width: 24px; height: 24px; color: var(--color-typo-alert);">${GitBranch}</span> <span>Ошибка: ${this._escapeHtml(message)}</span> <button class="retry-btn" style="margin-top: 4px; padding: 4px 8px; font-size: 10px; cursor: pointer; background: var(--color-control-bg-primary); color: #fff; border: none; border-radius: var(--radius-s, 4px);">Повторить</button> </div> `; const retryBtn = area.querySelector('.retry-btn'); if (retryBtn) { retryBtn.addEventListener('click', () => this.refresh()); } this._isLoading = false; } _renderChain() { const area = this._getContentArea(); if (!area) return; if (this._chain.length === 0) { this._showEmptyState('no-predecessors'); return; } const count = this._chain.length; const listHtml = this._chain.map((item, index) => { const markerClass = item.isRoot ? 'chain-marker--root' : item.isSelected ? 'chain-marker--selected' : ''; const ariaCurrent = item.isSelected ? 'aria-current="true"' : ''; const tabIndex = item.isSelected ? 'tabindex="0"' : 'tabindex="-1"'; return ` <li class="chain-item" role="listitem" data-index="${index}" data-activity-id="${item.id}" ${tabIndex} ${ariaCurrent}> <div class="chain-marker-column"> <div class="chain-marker ${markerClass}"></div> <div class="chain-connector"></div> </div> <div class="chain-content"> <div class="chain-code">${this._escapeHtml(item.activityCode)}</div> <div class="chain-name">${this._escapeHtml(item.taskName)}</div> <div class="chain-dates">${this._formatDate(item.startDate)} – ${this._formatDate(item.endDate)}</div> </div> </li> `; }).join(''); area.innerHTML = ` <ul class="chain-list" role="list" aria-label="Цепочка ведущих предшественников, ${count} работ"> ${listHtml} </ul> `; // Attach click handlers area.querySelectorAll('.chain-item').forEach(li => { li.addEventListener('click', this._onItemClick); li.addEventListener('mouseenter', (e) => this._showTooltip(e, li)); li.addEventListener('mouseleave', () => this._hideTooltip()); }); // Focus selected item const selected = area.querySelector('[aria-current="true"]'); if (selected) { selected.focus(); this._focusedIndex = this._chain.length - 1; } this._isLoading = false; } // ==================== Chain Building ==================== async _buildAndRender() { if (this._selectedActivityId === null) { this._showWaitingState(); return; } const dbId = this._databaseId || this._currentDbId || dataService.getActiveDatabaseId(); if (!dbId) { this._showWaitingState(); return; } this._showLoadingState(); const startTime = performance.now(); try { const chain = await this._buildChain(this._selectedActivityId, this._scheduleType, dbId); await this._enrichChainWithCodes(chain, this._scheduleType, dbId); this._chain = chain; const durationMs = Math.round(performance.now() - startTime); if (chain.length <= 1) { // Only selected activity, no predecessors const selected = chain[0]; let reason = 'no-driving'; if (selected.actualEndDate) { reason = 'completed'; } this._showEmptyState(reason); this._dispatchEvent('chain-empty', { activityId: this._selectedActivityId, reason: reason }); } else { this._renderChain(); this._dispatchEvent('chain-loaded', { count: chain.length, activityId: this._selectedActivityId, durationMs }); } // Accessibility announcement const ariaLive = this._getAriaLive(); if (ariaLive) { const last = chain[chain.length - 1]; ariaLive.textContent = `Цепочка обновлена. ${chain.length} работ. Выделена: ${last?.taskName || ''}`; } } catch (error) { console.error('[ActivityChainPanel] Build chain error:', error); this._showErrorState(error.message || 'Неизвестная ошибка'); this._dispatchEvent('chain-error', { activityId: this._selectedActivityId, message: error.message || 'Unknown error' }); } } /** * Build the driving predecessor chain * @param {number} activityId * @param {'current'|'target'} scheduleType * @param {string} dbId * @returns {Promise<ActivityChainItem[]>} */ async _buildChain(activityId, scheduleType, dbId) { /** @type {ActivityChainItem[]} */ const chain = []; const visited = new Set(); let currentId = activityId; let level = 0; // 1. Fetch selected activity data const selectedActivity = await this._fetchActivity(currentId, scheduleType, dbId); if (!selectedActivity) { throw new Error('Activity not found'); } // If selected activity is completed, no chain needed if (selectedActivity.actual_end_date) { // Return just the selected activity return [this._makeChainItem(selectedActivity, level, true, false)]; } // 2. Walk backwards through driving predecessors while (true) { if (visited.has(currentId)) { // Cycle detected — emit error but return partial chain this._dispatchEvent('chain-error', { activityId: this._selectedActivityId, message: 'Cycle detected in predecessor chain' }); break; } visited.add(currentId); // Fetch relationships where currentId is the successor const links = await this._fetchPredecessorLinks(currentId, scheduleType, dbId); if (links.length === 0) { break; // No predecessors } // 3. Driving Logic: check successor (currentId) free_float and actual_end_date // Note: we already know currentId is not completed (except on first iteration, // but we handle that above). For safety, re-fetch. const successor = await this._fetchActivity(currentId, scheduleType, dbId); if (!successor) break; const isDrivingSuccessor = successor.free_float <= 0 && !successor.actual_end_date; if (!isDrivingSuccessor) { break; // Successor is not driving } // Filter driving links: for each link, successor driving condition already met // (all links from this successor share the same successor activity) // We just need to pick the first one, or the one with minimal lag const drivingLinks = links; if (drivingLinks.length === 0) { break; } // 4. Pick first driving link (or with minimal lag) const chosen = drivingLinks.sort((a, b) => (a.lag_days || 0) - (b.lag_days || 0))[0]; // 5. Fetch predecessor activity const pred = await this._fetchActivity(chosen.predecessor_id, scheduleType, dbId); if (!pred) { break; // Predecessor not found } // Add predecessor to the beginning of chain chain.unshift(this._makeChainItem(pred, level, false, false, chosen)); // 5a. Stop condition: predecessor is completed AND has freeFloat <= 0 if (pred.actual_end_date && pred.free_float <= 0) { break; } currentId = pred.activity_id; level++; if (level >= this._maxChainLength) { this._dispatchEvent('chain-error', { activityId: this._selectedActivityId, message: 'Max chain length exceeded' }); break; } } // 6. Add selected activity at the end chain.push(this._makeChainItem(selectedActivity, level, true, false)); // 7. Mark first as root if (chain.length > 1) { chain[0].isRoot = true; } return chain; } /** * @private * @param {Object} row * @param {number} level * @param {boolean} isSelected * @param {boolean} isRoot * @param {Object} [link] * @returns {ActivityChainItem} */ _makeChainItem(row, level, isSelected, isRoot, link) { return { id: row.activity_id, activityCode: row.activity_code || '', taskName: row.task_name || '', startDate: row.start_date || '', endDate: row.end_date || '', actualEndDate: row.actual_end_date || undefined, freeFloat: row.free_float || 0, level, isSelected, isRoot, relationshipId: link ? link.relationship_id : undefined, lagDays: link ? link.lag_days : undefined, relationshipType: link ? link.relationship_type : undefined, gpPosition: undefined, drawingMark: undefined }; } /** * @private * @param {ActivityChainItem[]} chain * @param {'current'|'target'} scheduleType * @param {string} dbId */ async _enrichChainWithCodes(chain, scheduleType, dbId) { if (chain.length === 0) return; return this._enrichChainWithCodesJS(chain, dbId); } /** * @private * @param {ActivityChainItem[]} chain * @param {string} dbId */ _enrichChainWithCodesJS(chain, dbId) { const indexes = dataIndexer.getIndexes(dbId); if (!indexes) return; const targetNames = ['Позиция_ГП_МСГ', '!Марка чертежа (Основной)']; /** @type {Map<string, number>} */ const typeIdByName = new Map(); for (const type of indexes.codeTypeById.values()) { if (targetNames.includes(type.name)) { typeIdByName.set(type.name, type.typeId); } } if (typeIdByName.size === 0) return; for (const item of chain) { const codesByType = indexes.activityCodesByType.get(item.id); if (!codesByType) continue; const gpTypeId = typeIdByName.get('Позиция_ГП_МСГ'); if (gpTypeId != null) { const valueId = codesByType.get(gpTypeId); if (valueId != null) { const cv = indexes.codeValueById.get(valueId); if (cv) { item.gpPosition = { code: cv.shortName || '', description: cv.description || '' }; } } } const drawingTypeId = typeIdByName.get('!Марка чертежа (Основной)'); if (drawingTypeId != null) { const valueId = codesByType.get(drawingTypeId); if (valueId != null) { const cv = indexes.codeValueById.get(valueId); if (cv) { item.drawingMark = { code: cv.shortName || '', description: cv.description || '' }; } } } } } /** * @private * @param {number} activityId * @param {'current'|'target'} scheduleType * @param {string} dbId */ async _fetchActivity(activityId, scheduleType, dbId) { return this._fetchActivityJS(activityId, scheduleType, dbId); } /** * @private * @param {number} activityId * @param {'current'|'target'} scheduleType * @param {string} dbId */ _fetchActivityJS(activityId, scheduleType, dbId) { const indexes = dataIndexer.getIndexes(dbId); if (!indexes) return null; const bySchedule = indexes.activitiesBySchedule.get(scheduleType); const activity = bySchedule ? bySchedule.get(activityId) : indexes.activitiesById.get(activityId); if (!activity || (activity.scheduleType && activity.scheduleType !== scheduleType)) return null; const freeFloat = activity.freeFloat == null ? Infinity : Number(activity.freeFloat); return { activity_id: activity.id, activity_code: activity.activityCode || '', task_name: activity.taskName || '', start_date: activity.startDate || '', end_date: activity.endDate || '', actual_end_date: activity.actualEndDate || null, free_float: freeFloat }; } /** * @private * @param {number} successorId * @param {'current'|'target'} scheduleType * @param {string} dbId */ async _fetchPredecessorLinks(successorId, scheduleType, dbId) { return this._fetchPredecessorLinksJS(successorId, scheduleType, dbId); } /** * @private * @param {number} successorId * @param {'current'|'target'} scheduleType * @param {string} dbId */ _fetchPredecessorLinksJS(successorId, scheduleType, dbId) { const indexes = dataIndexer.getIndexes(dbId); if (!indexes || !indexes.relationships) return []; const successor = indexes.activitiesById.get(successorId); if (!successor || successor.scheduleType !== scheduleType) return []; return indexes.relationships .filter(r => r.successorId === successorId) .map(r => ({ relationship_id: r.relationshipId, predecessor_id: r.predecessorId, relationship_type: r.type, lag_days: r.lagDays != null ? Number(r.lagDays) : 0 })); } // ==================== Event Handlers ==================== _onSelectionChange(event) { const detail = event.detail || {}; const selectedIds = detail.selectedIds || []; const activityIds = detail.activityIds || []; if (selectedIds.length === 0 && activityIds.length === 0) { this.clear(); return; } // Prefer numeric activityIds if provided (COMP-022 integration) // Fallback: parse legacy act-123 format from selectedIds const rawId = activityIds.length > 0 ? activityIds[0] : selectedIds[0]; const id = Number(rawId); if (isNaN(id)) return; // Try to infer scheduleType from event detail or current state const scheduleType = detail.scheduleType || this._scheduleType; this.setSelectedActivity(id, scheduleType); } _onDatabaseReady(event) { const dbId = event.detail?.databaseId; if (dbId) { this._currentDbId = dbId; } // If we have a selected activity, refresh if (this._selectedActivityId !== null) { this.refresh(); } else { this.clear(); } } _onDatabaseRemoved(event) { const dbId = event.detail?.databaseId; if (!dbId || dbId === this._currentDbId) { this.clear(); this._currentDbId = null; } } _onItemClick(event) { const item = event.currentTarget; const activityId = Number(item.dataset.activityId); const index = Number(item.dataset.index); if (isNaN(activityId)) return; const chainItem = this._chain[index]; if (!chainItem) return; this._dispatchEvent('chain-item-click', { activityId, activityCode: chainItem.activityCode, level: chainItem.level }); } _onKeyDown(event) { const items = this.querySelectorAll('.chain-item'); if (items.length === 0) return; let newIndex = this._focusedIndex ?? items.length - 1; switch (event.key) { case 'ArrowDown': event.preventDefault(); newIndex = Math.min(items.length - 1, newIndex + 1); break; case 'ArrowUp': event.preventDefault(); newIndex = Math.max(0, newIndex - 1); break; case 'Home': event.preventDefault(); newIndex = 0; break; case 'End': event.preventDefault(); newIndex = items.length - 1; break; case 'Enter': case ' ': event.preventDefault(); if (newIndex >= 0 && newIndex < items.length) { items[newIndex].click(); } return; default: return; } this._focusedIndex = newIndex; items[newIndex].focus(); } _disableHostDrag(event) { // Prevent drag when interacting with inputs or buttons inside the panel if (event.target.closest('input, textarea, select, button, [contenteditable]')) { event.stopPropagation(); const header = this.querySelector('.module-header') || this.querySelector('h3'); if (header) { header.setAttribute('draggable', 'false'); setTimeout(() => header.setAttribute('draggable', 'true'), 0); } } } _restoreHostDrag() { const header = this.querySelector('.module-header') || this.querySelector('h3'); if (header) { header.setAttribute('draggable', 'true'); } } _showTooltip(event, li) { const index = Number(li.dataset.index); const item = this._chain[index]; if (!item) return; const overlay = this.querySelector('.chain-tooltip-overlay'); const content = this.querySelector('.chain-tooltip-content'); if (!overlay || !content) return; let html = `<div class="chain-tooltip-name">${this._escapeHtml(item.taskName)}</div>`; if (item.gpPosition) { html += `<div class="chain-tooltip-row"><span class="chain-tooltip-label">Позиция ГП:</span> ${this._escapeHtml(item.gpPosition.code)} — ${this._escapeHtml(item.gpPosition.description)}</div>`; } if (item.drawingMark) { html += `<div class="chain-tooltip-row"><span class="chain-tooltip-label">Марка чертежа:</span> ${this._escapeHtml(item.drawingMark.code)} — ${this._escapeHtml(item.drawingMark.description)}</div>`; } content.innerHTML = html; const rect = li.getBoundingClientRect(); const tooltipWidth = 280; const gap = 8; let left = rect.right + gap; let top = rect.top; if (left + tooltipWidth > window.innerWidth) { left = rect.left - tooltipWidth - gap; } if (left < 0) { left = rect.left; top = rect.bottom + gap; } overlay.style.left = `${left}px`; overlay.style.top = `${top}px`; overlay.style.display = 'block'; } _hideTooltip() { const overlay = this.querySelector('.chain-tooltip-overlay'); if (overlay) overlay.style.display = 'none'; } // ==================== Utilities ==================== _dispatchEvent(name, detail) { this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true })); } /** * @param {string} dateStr * @returns {string} */ _formatDate(dateStr) { if (!dateStr) return ''; try { const d = new Date(dateStr); if (isNaN(d.getTime())) return dateStr; return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' }); } catch { return dateStr; } } /** * @param {string} text * @returns {string} */ _escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } customElements.define('activity-chain-panel', ActivityChainPanel); export { ActivityChainPanel };