This commit is contained in:
Mateusz Gruszczyński
2026-09-04 08:59:11 +02:00
parent 39f50eba2e
commit 7fd5921ba9
17 changed files with 276 additions and 1268 deletions
+20 -14
View File
@@ -280,10 +280,10 @@
</select></label><label><span data-i18n="simulation.show">Show</span><select
id="simulationTarget"></select></label></div>
<div class="simulation-summary-grid" id="simulationSummary"></div>
<div class="panel simulation-flow-shell">
<div class="simulation-flow-head">
<section class="panel simulation-section-panel" aria-labelledby="simulationFlowTitle">
<div class="simulation-panel-head">
<div><span class="eyebrow" data-i18n="simulation.flowEyebrow">Live flow</span>
<h2 data-i18n="simulation.flowTitle">Automation flow board</h2>
<h2 id="simulationFlowTitle" data-i18n="simulation.flowTitle">Automation flow board</h2>
</div>
<div class="flow-legend"><span><i class="flow-legend-dot input"></i><b
data-i18n="simulation.legendInput">Input</b></span><span><i class="flow-legend-dot logic"></i><b
@@ -293,21 +293,27 @@
<div class="simulation-flow-scroll">
<div class="simulation-flow-board" id="simulationFlowBoard"></div>
</div>
</div>
<div class="panel simulation-timeline-panel">
<div class="simulation-section-head">
</section>
<section class="panel simulation-section-panel" aria-labelledby="simulationTimelineTitle">
<div class="simulation-panel-head">
<div><span class="eyebrow" data-i18n="simulation.timelineEyebrow">Timeline</span>
<h2 data-i18n="simulation.timelineTitle">What will happen next</h2>
<h2 id="simulationTimelineTitle" data-i18n="simulation.timelineTitle">What will happen next</h2>
</div>
</div>
<div class="simulation-timeline" id="simulationTimeline"></div>
</div>
<div class="simulation-section-head">
<div><span class="eyebrow" data-i18n="simulation.rulesEyebrow">Rules</span>
<h2 data-i18n="simulation.rulesTitle">Automation rules</h2>
<div class="simulation-panel-body">
<div class="simulation-timeline" id="simulationTimeline"></div>
</div>
</div>
<div class="simulation-rules-grid" id="simulationRules"></div>
</section>
<section class="panel simulation-section-panel" aria-labelledby="simulationRulesTitle">
<div class="simulation-panel-head">
<div><span class="eyebrow" data-i18n="simulation.rulesEyebrow">Rules</span>
<h2 id="simulationRulesTitle" data-i18n="simulation.rulesTitle">Automation rules</h2>
</div>
</div>
<div class="simulation-panel-body">
<div class="simulation-rules-grid" id="simulationRules"></div>
</div>
</section>
</section>
<section class="view" data-view="history">
+30 -106
View File
@@ -151,6 +151,9 @@ function updateChartFullscreenButton(card) {
function closeChartPreview(card) {
if (!card) return;
card.classList.remove('chart-fullscreen-fallback');
card.removeAttribute('role');
card.removeAttribute('aria-modal');
card.removeAttribute('aria-label');
document.body.classList.remove('chart-fullscreen-open');
updateChartFullscreenButton(card);
const canvas = card.querySelector('canvas[id]');
@@ -158,6 +161,7 @@ function closeChartPreview(card) {
}
function toggleChartFullscreen(id) {
if (!window.matchMedia('(min-width: 761px)').matches) return;
const canvas = document.getElementById(id);
const card = canvas?.closest('.history-chart-card');
if (!card) return;
@@ -172,12 +176,10 @@ function toggleChartFullscreen(id) {
}
card.classList.add('chart-fullscreen-fallback');
card.setAttribute('role', 'dialog');
card.setAttribute('aria-modal', 'true');
card.setAttribute('aria-label', card.querySelector('h3')?.textContent || tr('history.fullscreen'));
document.body.classList.add('chart-fullscreen-open');
if (window.matchMedia('(max-width: 760px)').matches) {
app.chartZooms[id] = 1;
canvas.dataset.chartZoom = '1';
if (canvas.parentElement) canvas.parentElement.scrollLeft = 0;
}
updateChartFullscreenButton(card);
requestAnimationFrame(() => redrawHistoryChart(id));
}
@@ -186,15 +188,12 @@ function prepareCanvas(canvas, height) {
const rect = canvas.getBoundingClientRect();
const wrap = canvas.parentElement;
const wrapWidth = Math.floor(wrap?.clientWidth || rect.width || 720);
const card = canvas.closest('.history-chart-card');
const fullscreen = chartCardIsFullscreen(card);
const mobileFullscreen = fullscreen && window.matchMedia('(max-width: 760px)').matches;
const fullscreen = chartCardIsFullscreen(canvas.closest('.history-chart-card'));
const wrapHeight = fullscreen ? Math.floor(wrap?.clientHeight || 0) : 0;
const naturalMobileHeight = Math.round(clamp(wrapWidth * .72, 260, 360));
const actualHeight = mobileFullscreen ? Math.min(wrapHeight || naturalMobileHeight, naturalMobileHeight) : (wrapHeight || height);
const actualHeight = wrapHeight || height;
const zoom = clamp(Number(canvas.dataset.chartZoom || 1), 1, MAX_CHART_ZOOM);
// Normal charts may scroll horizontally. Fullscreen 100% always fits the viewport width.
const baseWidth = fullscreen ? Math.max(1, wrapWidth) : Math.max(720, wrapWidth);
// Keep the original chart geometry on narrow screens and let the wrapper scroll horizontally.
const baseWidth = Math.max(720, wrapWidth);
const width = Math.max(1, Math.floor(baseWidth * zoom));
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr; canvas.height = actualHeight * dpr;
@@ -262,10 +261,7 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
.filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value))
.sort((a, b) => a.ts - b.ts)
}));
const touches = new Map();
let drag = null;
let pinch = null;
let touchPan = null;
const hide = () => { tooltip.hidden = true; line.hidden = true; };
const hideSelection = () => { if (selection) selection.hidden = true; };
@@ -323,114 +319,42 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
};
canvas.onpointerdown = event => {
if (event.pointerType === 'mouse') {
if (event.button !== 0) return;
const startX = canvasX(event.clientX);
drag = { pointerId: event.pointerId, startX, lastX: startX, moved: false };
hide(); hideSelection();
canvas.setPointerCapture?.(event.pointerId);
event.preventDefault();
return;
}
if (event.pointerType === 'touch') {
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (touches.size === 1) {
touchPan = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, scrollLeft: wrap.scrollLeft, moved: false };
showFromClientX(event.clientX);
}
if (touches.size === 2) {
touchPan = null;
const points = [...touches.values()];
const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
const midX = (points[0].x + points[1].x) / 2;
const rect = canvas.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
pinch = {
startDistance: Math.max(1, distance),
startZoom: clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM),
baseWidth: (rect.width || width) / clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM),
anchorRatio: clamp((midX - rect.left) / Math.max(1, rect.width), 0, 1),
viewportX: clamp(midX - wrapRect.left, 0, wrap.clientWidth),
previewZoom: clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM),
};
hide();
event.preventDefault();
}
}
if (event.pointerType !== 'mouse' || event.button !== 0) return;
const startX = canvasX(event.clientX);
drag = { pointerId: event.pointerId, startX, lastX: startX, moved: false };
hide(); hideSelection();
canvas.setPointerCapture?.(event.pointerId);
event.preventDefault();
};
canvas.onpointermove = event => {
if (event.pointerType === 'touch') return;
if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) {
updateSelection(canvasX(event.clientX));
event.preventDefault();
return;
}
if (event.pointerType === 'touch' && touches.has(event.pointerId)) {
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (pinch && touches.size >= 2) {
const points = [...touches.values()].slice(0, 2);
const distance = Math.hypot(points[0].x - points[1].x, points[0].y - points[1].y);
pinch.previewZoom = clamp(pinch.startZoom * (distance / pinch.startDistance), 1, MAX_CHART_ZOOM);
const previewWidth = pinch.baseWidth * pinch.previewZoom;
canvas.style.width = `${previewWidth}px`;
wrap.scrollLeft = Math.max(0, pinch.anchorRatio * previewWidth - pinch.viewportX);
const reset = canvas.closest('.history-chart-card')?.querySelector('[data-chart-zoom="reset"]');
if (reset) reset.textContent = `${Math.round(pinch.previewZoom * 100)}%`;
event.preventDefault();
} else if (touchPan?.pointerId === event.pointerId && touches.size === 1) {
const dx = event.clientX - touchPan.startX;
const dy = event.clientY - touchPan.startY;
if (Math.abs(dx) > 6 && Math.abs(dx) > Math.abs(dy)) {
touchPan.moved = true;
hide();
wrap.scrollLeft = Math.max(0, touchPan.scrollLeft - dx);
event.preventDefault();
}
}
return;
}
showFromClientX(event.clientX);
};
const finishPointer = event => {
if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) {
const currentX = canvasX(event.clientX);
const selectedWidth = Math.abs(currentX - drag.startX);
const center = (currentX + drag.startX) / 2;
const moved = drag.moved && selectedWidth >= 12;
drag = null; hideSelection();
try { canvas.releasePointerCapture?.(event.pointerId); } catch (_) { }
if (moved) {
const currentZoom = clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM);
const factor = Math.max(1, wrap.clientWidth / Math.max(1, selectedWidth));
setChartZoom(canvas.id, currentZoom * factor, center / Math.max(1, width), wrap.clientWidth / 2);
} else showFromClientX(event.clientX);
return;
}
if (event.pointerType === 'touch') {
const wasPan = touchPan?.pointerId === event.pointerId ? touchPan : null;
touches.delete(event.pointerId);
if (pinch && touches.size < 2) {
const completed = pinch;
pinch = null;
setChartZoom(canvas.id, completed.previewZoom, completed.anchorRatio, completed.viewportX);
} else if (wasPan && !wasPan.moved) showFromClientX(event.clientX);
if (touches.size === 0) touchPan = null;
}
if (event.pointerType !== 'mouse' || drag?.pointerId !== event.pointerId) return;
const currentX = canvasX(event.clientX);
const selectedWidth = Math.abs(currentX - drag.startX);
const center = (currentX + drag.startX) / 2;
const moved = drag.moved && selectedWidth >= 12;
drag = null; hideSelection();
try { canvas.releasePointerCapture?.(event.pointerId); } catch (_) { }
if (moved) {
const currentZoom = clamp(Number(app.chartZooms[canvas.id] || 1), 1, MAX_CHART_ZOOM);
const factor = Math.max(1, wrap.clientWidth / Math.max(1, selectedWidth));
setChartZoom(canvas.id, currentZoom * factor, center / Math.max(1, width), wrap.clientWidth / 2);
} else showFromClientX(event.clientX);
};
canvas.onpointerup = finishPointer;
canvas.onpointercancel = event => {
if (event.pointerType === 'mouse' && drag?.pointerId === event.pointerId) { drag = null; hideSelection(); }
if (event.pointerType === 'touch') {
touches.delete(event.pointerId);
if (touchPan?.pointerId === event.pointerId) touchPan = null;
if (pinch && touches.size < 2) {
const completed = pinch;
pinch = null;
setChartZoom(canvas.id, completed.previewZoom, completed.anchorRatio, completed.viewportX);
} else hide();
}
};
canvas.onpointerleave = event => { if (event.pointerType !== 'touch' && !drag) hide(); };
canvas.onkeydown = event => {
-5
View File
@@ -307,11 +307,6 @@ function setTheme(theme) {
applyTheme();
}
function cycleTheme() {
const modes = ['system', 'light', 'dark'];
const index = modes.indexOf(app.theme);
setTheme(modes[(index + 1) % modes.length]);
}
async function api(path, options = {}) {
const headers = new Headers(options.headers || {});
+1 -1
View File
@@ -406,7 +406,7 @@ function renderSimulationPage() {
}).join('') : `<div class="empty">${esc(tr('plan.noEvents'))}</div>`;
const activeRules = (plan.rules || []).filter(rule => rule.enabled && simulationRuleVisible(rule, zones));
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="panel simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
rulesHost.innerHTML = activeRules.length ? activeRules.map(rule => `<article class="simulation-rule-card"><div class="simulation-rule-head"><div><span class="eyebrow">${esc(tr('common.automation'))}</span><h3>${esc(rule.name)}</h3></div><span class="badge active">${esc(automationTriggerLabel(rule))}</span></div><p>${esc(tr('simulation.ruleOnDevice', { device: rule.action_device_name || tr('common.noDevice') }))}</p><div class="simulation-rule-action">${esc(simulationRuleAction(rule))}</div><div class="simulation-rule-meta"><small>${esc(tr('automations.last'))}: ${esc(dateTime(rule.last_fired_at))}</small><small>${esc(tr('simulation.nextReady'))}: ${esc(dateTime(rule.next_ready_at))}</small></div></article>`).join('') : `<div class="empty">${esc(tr('simulation.noRules'))}</div>`;
}
function scheduleControlPlanLoad() {
-3
View File
@@ -10,9 +10,6 @@ function deviceFeaturePanel(device) {
return `<div class="device-capability-panel"><small>${esc(tr('devices.features'))}</small><div class="device-capability-buttons">${features.map(([field, , label]) => `<button class="${device[field] ? 'active' : ''}" data-action="toggle" data-field="${field}" data-device="${esc(device.id)}">${esc(label)}</button>`).join('')}</div></div>`;
}
function deviceZoneDisabled(deviceId) {
return app.zones.some(zone => zone.device_id === deviceId && zone.enabled === false);
}
function zoneForDevice(deviceId) {
return app.zones.find(zone => zone.device_id === deviceId) || null;
+106 -637
View File
File diff suppressed because it is too large Load Diff