This commit is contained in:
Mateusz Gruszczyński
2026-09-17 10:06:11 +02:00
parent 2f0bf20033
commit 32f8a32bd2
12 changed files with 169 additions and 61 deletions
+128 -21
View File
@@ -4,6 +4,7 @@ const PUBLIC_CHART_COLORS = ['--accent', '--info', '--warning', '--purple', '--d
const $ = selector => document.querySelector(selector);
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
const PUBLIC_CHART_RANGES = [6, 24, 168, 720, 2160, 8760];
const PUBLIC_MAX_CHART_ZOOM = 16;
function basePathFromScript() {
const src = document.currentScript?.src || '';
@@ -23,11 +24,8 @@ const publicLanguagePacks = new Map();
let publicChartLanguage = PUBLIC_DEFAULT_LANGUAGE;
let publicChartTranslations = {};
let publicChartLocale = PUBLIC_DEFAULT_LANGUAGE;
function publicCookie(name) {
const row = document.cookie.split('; ').find(item => item.startsWith(`${name}=`));
return row ? decodeURIComponent(row.split('=').slice(1).join('=')) : '';
}
let publicChartZoom = 1;
const publicChartHiddenSeries = new Set();
function publicTr(key, params = {}) {
const template = publicChartTranslations[key] ?? key;
@@ -85,7 +83,8 @@ function preciseTime(timestamp, locale) {
function prepareCanvas(canvas, height = 520) {
const wrap = canvas.parentElement;
const width = Math.max(720, Math.floor(wrap?.clientWidth || canvas.getBoundingClientRect().width || 720));
const baseWidth = Math.max(720, Math.floor(wrap?.clientWidth || canvas.getBoundingClientRect().width || 720));
const width = Math.max(1, Math.floor(baseWidth * publicChartZoom));
const actualHeight = Math.max(180, Math.floor(wrap?.clientHeight || height));
const dpr = window.devicePixelRatio || 1;
canvas.width = Math.floor(width * dpr);
@@ -98,12 +97,21 @@ function prepareCanvas(canvas, height = 520) {
return { ctx, width, height: actualHeight };
}
function publicChartSeriesKey(item, index) {
return item.key || `${index}:${item.label}`;
}
function isPublicChartSeriesHidden(item, index) {
return publicChartHiddenSeries.has(publicChartSeriesKey(item, index));
}
function renderLegend(series) {
const host = $('#publicCustomChartLegend');
host.innerHTML = series.map((item, index) => {
const color = cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e');
const hidden = isPublicChartSeriesHidden(item, index);
const dashed = item.dashed ? ' is-dashed' : '';
return `<span class="legend-item public-chart-legend-item"><i class="legend-line${dashed}" style="--legend-color:${color}"></i><span>${escapeHtml(item.label)}</span></span>`;
const title = publicTr(hidden ? 'history.legendShow' : 'history.legendHide', { name: item.label });
return `<button type="button" class="legend-item public-chart-legend-item${hidden ? ' is-hidden' : ''}" data-public-chart-legend="${index}" aria-pressed="${hidden ? 'false' : 'true'}" title="${escapeHtml(title)}"><i class="legend-line${dashed}" style="--legend-color:${escapeHtml(item.color)}"></i><span>${escapeHtml(item.label)}</span></button>`;
}).join('');
}
@@ -117,6 +125,13 @@ function drawEmpty(canvas, message) {
ctx.font = '13px system-ui';
ctx.textAlign = 'center';
ctx.fillText(message, width / 2, height / 2);
const wrap = canvas.parentElement;
const tooltip = wrap?.querySelector('.chart-tooltip');
const line = wrap?.querySelector('.chart-hover-line');
if (tooltip) tooltip.hidden = true;
if (line) line.hidden = true;
canvas.onpointermove = null;
canvas.onpointerleave = null;
}
function nearestPoint(points, targetTs) {
@@ -142,7 +157,7 @@ function bindTooltip(canvas, series, geometry, locale) {
const timestampPoints = timestamps.map(ts => ({ ts }));
const seriesPoints = series.map((item, index) => ({
item,
color: cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e'),
color: item.color || cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e'),
points: item.points.map(point => ({ ts: new Date(point.timestamp).getTime(), value: Number(point.value) }))
.filter(point => Number.isFinite(point.ts) && Number.isFinite(point.value))
.sort((a, b) => a.ts - b.ts)
@@ -178,12 +193,13 @@ function bindTooltip(canvas, series, geometry, locale) {
}
function drawChart(canvas, payload, locale) {
const series = (payload.series || []).map((item, index) => ({
const allSeries = (payload.series || []).map((item, index) => ({
...item,
color: cssColor(PUBLIC_CHART_COLORS[index % PUBLIC_CHART_COLORS.length], '#3ecf8e'),
points: Array.isArray(item.points) ? item.points : []
}));
renderLegend(series);
renderLegend(allSeries);
const series = allSeries.filter((item, index) => !isPublicChartSeriesHidden(item, index));
const allPoints = series.flatMap(item => item.points.map(point => ({ ...point, value: Number(point.value) })))
.filter(point => Number.isFinite(point.value) && Number.isFinite(new Date(point.timestamp).getTime()));
if (!allPoints.length) return drawEmpty(canvas, payload.no_data_label || publicTr('publicChart.noData'));
@@ -288,6 +304,80 @@ function redrawPublicChart() {
drawChart($('#publicCustomChart'), publicChartPayload, publicChartLocale);
}
function setPublicChartZoom(value, anchorRatio = null, anchorViewportX = null) {
const canvas = $('#publicCustomChart');
const wrap = canvas?.parentElement;
if (!canvas || !wrap || !publicChartPayload) return;
const oldWidth = canvas.getBoundingClientRect().width || 1;
const ratio = Number.isFinite(anchorRatio)
? clamp(anchorRatio, 0, 1)
: clamp((wrap.scrollLeft + wrap.clientWidth / 2) / oldWidth, 0, 1);
const viewportX = Number.isFinite(anchorViewportX) ? anchorViewportX : wrap.clientWidth / 2;
const next = clamp(Number(value) || 1, 1, PUBLIC_MAX_CHART_ZOOM);
if (Math.abs(next - publicChartZoom) < 0.001) return;
publicChartZoom = next;
redrawPublicChart();
const nextWidth = canvas.getBoundingClientRect().width || 1;
wrap.scrollLeft = Math.max(0, ratio * nextWidth - viewportX);
}
function bindPublicChartTouchGestures() {
const canvas = $('#publicCustomChart');
const wrap = canvas?.parentElement;
if (!canvas || !wrap) return;
let pinch = null;
let pan = null;
const distance = touches => Math.hypot(
touches[0].clientX - touches[1].clientX,
touches[0].clientY - touches[1].clientY
);
const beginPinch = touches => {
const rect = canvas.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
const centerX = (touches[0].clientX + touches[1].clientX) / 2;
pinch = {
distance: Math.max(1, distance(touches)),
zoom: publicChartZoom,
anchorRatio: clamp((centerX - rect.left) / Math.max(1, rect.width), 0, 1),
anchorViewportX: clamp(centerX - wrapRect.left, 0, wrap.clientWidth),
};
pan = null;
};
const beginPan = touch => {
pan = { clientX: touch.clientX, scrollLeft: wrap.scrollLeft };
};
canvas.addEventListener('touchstart', event => {
if (event.touches.length >= 2) {
event.preventDefault();
beginPinch(event.touches);
} else if (event.touches.length === 1) {
beginPan(event.touches[0]);
}
}, { passive: false });
canvas.addEventListener('touchmove', event => {
if (event.touches.length >= 2) {
event.preventDefault();
if (!pinch) beginPinch(event.touches);
const scale = distance(event.touches) / Math.max(1, pinch.distance);
setPublicChartZoom(pinch.zoom * scale, pinch.anchorRatio, pinch.anchorViewportX);
return;
}
if (event.touches.length === 1 && pan && wrap.scrollWidth > wrap.clientWidth + 1) {
event.preventDefault();
wrap.scrollLeft = pan.scrollLeft + (pan.clientX - event.touches[0].clientX);
}
}, { passive: false });
canvas.addEventListener('touchend', event => {
pinch = null;
if (event.touches.length === 1) beginPan(event.touches[0]);
else pan = null;
}, { passive: true });
canvas.addEventListener('touchcancel', () => { pinch = null; pan = null; }, { passive: true });
}
async function loadPublicChart(hours = requestedPublicChartHours()) {
const token = publicChartToken();
const title = $('#publicChartTitle');
@@ -297,6 +387,7 @@ async function loadPublicChart(hours = requestedPublicChartHours()) {
const range = $('#publicChartHours');
if (!token.startsWith('chart_')) {
await setPublicChartLanguage('en');
title.textContent = publicTr('publicChart.title');
hint.textContent = '';
error.hidden = false;
@@ -306,16 +397,19 @@ async function loadPublicChart(hours = requestedPublicChartHours()) {
}
if (range) range.disabled = true;
if (hint) hint.textContent = publicTr('publicChart.loading');
if (hint) hint.textContent = Object.keys(publicChartTranslations).length ? publicTr('publicChart.loading') : 'Loading…';
error.hidden = true;
const response = await fetch(withBase(publicChartApiPath(token, hours)), { headers: { Accept: 'application/json' }, cache: 'no-store' });
if (!response.ok) {
let message = `${response.status}`;
try { message = (await response.json()).error || message; } catch (_) { }
throw new Error(message);
if (response.status === 404) await setPublicChartLanguage('en');
const requestError = new Error(message);
requestError.status = response.status;
throw requestError;
}
const payload = await response.json();
await setPublicChartLanguage(payload.lang || publicChartLanguage);
await setPublicChartLanguage(payload.lang || 'en');
title.textContent = payload.title || publicTr('publicChart.title');
hint.textContent = payload.hint || publicTr('publicChart.periodHint', { hours: payload.hours });
canvas.setAttribute('aria-label', title.textContent);
@@ -329,6 +423,18 @@ async function loadPublicChart(hours = requestedPublicChartHours()) {
if (range) range.disabled = false;
}
$('#publicCustomChartLegend')?.addEventListener('click', event => {
const button = event.target.closest('button[data-public-chart-legend]');
if (!button || !publicChartPayload) return;
const index = Number(button.dataset.publicChartLegend);
const item = publicChartPayload.series?.[index];
if (!item) return;
const key = publicChartSeriesKey(item, index);
if (publicChartHiddenSeries.has(key)) publicChartHiddenSeries.delete(key);
else publicChartHiddenSeries.add(key);
redrawPublicChart();
});
$('#publicChartHours')?.addEventListener('change', async event => {
const hours = Number(event.currentTarget.value);
if (!Number.isInteger(hours) || hours < 1 || hours > 87600) return;
@@ -351,16 +457,15 @@ window.addEventListener('resize', () => {
});
async function initPublicChart() {
try {
await setPublicChartLanguage(publicCookie('gree_controller_language') || PUBLIC_DEFAULT_LANGUAGE);
} catch (error) {
console.error('Public chart language initialization failed:', error);
} finally {
document.documentElement.classList.remove('i18n-loading');
}
bindPublicChartTouchGestures();
try {
await loadPublicChart();
} catch (error) {
if (!Object.keys(publicChartTranslations).length) {
try { await setPublicChartLanguage('en'); } catch (languageError) {
console.error('Public chart fallback language initialization failed:', languageError);
}
}
const host = $('#publicChartError');
host.hidden = false;
host.textContent = publicChartTranslations['publicChart.loadFailed']
@@ -369,6 +474,8 @@ async function initPublicChart() {
$('#publicChartHint').textContent = '';
$('#publicChartHours').disabled = false;
drawEmpty($('#publicCustomChart'), host.textContent);
} finally {
document.documentElement.classList.remove('i18n-loading');
}
}