v0.9.5
This commit is contained in:
+250
-250
@@ -1,13 +1,13 @@
|
||||
function encodeChartSpec(series) {
|
||||
try {
|
||||
const raw = JSON.stringify(series || []);
|
||||
return btoa(raw).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
|
||||
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
} catch (_) { return ''; }
|
||||
}
|
||||
|
||||
function decodeChartSpec(encoded) {
|
||||
try {
|
||||
const padded = String(encoded || '').replace(/-/g,'+').replace(/_/g,'/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '=');
|
||||
const padded = String(encoded || '').replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(String(encoded || '').length / 4) * 4, '=');
|
||||
const value = JSON.parse(atob(padded));
|
||||
return Array.isArray(value) ? value.filter(item => typeof item === 'string').slice(0, 16) : [];
|
||||
} catch (_) { return []; }
|
||||
@@ -22,7 +22,7 @@ function historyEntityOptions() {
|
||||
app.settings?.home_assistant?.outdoor_entity_id,
|
||||
].filter(Boolean))].sort();
|
||||
const sensors = entities.map(entity => `<option value="${esc(entity)}">${esc(haSensorLabel(entity))}</option>`).join('');
|
||||
return {zones, devices, sensors, entities};
|
||||
return { zones, devices, sensors, entities };
|
||||
}
|
||||
|
||||
function renderHistoryNavigation() {
|
||||
@@ -31,13 +31,13 @@ function renderHistoryNavigation() {
|
||||
const options = historyEntityOptions();
|
||||
if (app.historyTab === 'zones') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.zone'))}</span><select id="historyZoneSelect"><option value="all">${esc(tr('history.allZones'))}</option>${options.zones}</select></label>`;
|
||||
const select=$('#historyZoneSelect'); if ([...select.options].some(option=>option.value===app.historyZone)) select.value=app.historyZone;
|
||||
const select = $('#historyZoneSelect'); if ([...select.options].some(option => option.value === app.historyZone)) select.value = app.historyZone;
|
||||
} else if (app.historyTab === 'devices') {
|
||||
host.innerHTML = `<label><span>${esc(tr('common.device'))}</span><select id="historyDeviceSelect"><option value="all">${esc(tr('history.allDevices'))}</option>${options.devices}</select></label>`;
|
||||
const select=$('#historyDeviceSelect'); if ([...select.options].some(option=>option.value===app.historyDevice)) select.value=app.historyDevice;
|
||||
const select = $('#historyDeviceSelect'); if ([...select.options].some(option => option.value === app.historyDevice)) select.value = app.historyDevice;
|
||||
} else if (app.historyTab === 'sensors') {
|
||||
host.innerHTML = `<label><span>${esc(tr('history.haSensor'))}</span><select id="historySensorSelect"><option value="all">${esc(tr('history.allSensors'))}</option>${options.sensors}</select></label>`;
|
||||
const select=$('#historySensorSelect'); if ([...select.options].some(option=>option.value===app.historySensor)) select.value=app.historySensor;
|
||||
const select = $('#historySensorSelect'); if ([...select.options].some(option => option.value === app.historySensor)) select.value = app.historySensor;
|
||||
} else if (app.historyTab === 'custom') {
|
||||
host.innerHTML = `<span class="history-context-hint">${esc(tr('history.customHint'))}</span>`;
|
||||
} else {
|
||||
@@ -51,7 +51,7 @@ async function loadHistory() {
|
||||
const hours = $('#historyHours')?.value || '24';
|
||||
try {
|
||||
const data = await api(`/api/history?scope=overview&hours=${encodeURIComponent(hours)}&limit=20000`);
|
||||
app.historyData = {zones:data.zones || [], devices:data.devices || [], sensors:data.sensors || []};
|
||||
app.historyData = { zones: data.zones || [], devices: data.devices || [], sensors: data.sensors || [] };
|
||||
app.historyCounts = data.counts || {};
|
||||
renderHistoryNavigation();
|
||||
renderHistoryPage();
|
||||
@@ -62,7 +62,7 @@ async function loadHistory() {
|
||||
} finally { app.historyLoading = false; }
|
||||
}
|
||||
|
||||
const HISTORY_COLORS = ['--accent','--info','--warning','--purple','--danger','--teal','--orange','--blue'];
|
||||
const HISTORY_COLORS = ['--accent', '--info', '--warning', '--purple', '--danger', '--teal', '--orange', '--blue'];
|
||||
const MAX_CHART_ZOOM = 16;
|
||||
const chartRuntime = new Map();
|
||||
|
||||
@@ -71,57 +71,57 @@ function chartSeriesKey(item, index) {
|
||||
}
|
||||
|
||||
function isChartSeriesHidden(chartId, item, index) {
|
||||
return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item,index)];
|
||||
return !!app.chartHiddenSeries[chartId]?.[chartSeriesKey(item, index)];
|
||||
}
|
||||
|
||||
function setChartSeriesHidden(chartId, item, index, hidden) {
|
||||
if (!app.chartHiddenSeries[chartId]) app.chartHiddenSeries[chartId] = {};
|
||||
const key = chartSeriesKey(item,index);
|
||||
const key = chartSeriesKey(item, index);
|
||||
if (hidden) app.chartHiddenSeries[chartId][key] = true;
|
||||
else delete app.chartHiddenSeries[chartId][key];
|
||||
}
|
||||
|
||||
function updateChartZoomControls(id) {
|
||||
const canvas=document.getElementById(id);
|
||||
const card=canvas?.closest('.history-chart-card');
|
||||
if(!card) return;
|
||||
const zoom=clamp(Number(app.chartZooms[id] || 1),1,MAX_CHART_ZOOM);
|
||||
const reset=card.querySelector('[data-chart-zoom="reset"]');
|
||||
const out=card.querySelector('[data-chart-zoom="out"]');
|
||||
const zoomIn=card.querySelector('[data-chart-zoom="in"]');
|
||||
if(reset) reset.textContent=`${Math.round(zoom*100)}%`;
|
||||
if(out) out.disabled=zoom<=1;
|
||||
if(zoomIn) zoomIn.disabled=zoom>=MAX_CHART_ZOOM;
|
||||
const canvas = document.getElementById(id);
|
||||
const card = canvas?.closest('.history-chart-card');
|
||||
if (!card) return;
|
||||
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const reset = card.querySelector('[data-chart-zoom="reset"]');
|
||||
const out = card.querySelector('[data-chart-zoom="out"]');
|
||||
const zoomIn = card.querySelector('[data-chart-zoom="in"]');
|
||||
if (reset) reset.textContent = `${Math.round(zoom * 100)}%`;
|
||||
if (out) out.disabled = zoom <= 1;
|
||||
if (zoomIn) zoomIn.disabled = zoom >= MAX_CHART_ZOOM;
|
||||
}
|
||||
|
||||
function redrawHistoryChart(id) {
|
||||
const runtime=chartRuntime.get(id);
|
||||
const canvas=document.getElementById(id);
|
||||
if(!runtime || !canvas) return;
|
||||
canvas.dataset.chartZoom=String(clamp(Number(app.chartZooms[id] || 1),1,MAX_CHART_ZOOM));
|
||||
drawLineChart(canvas,runtime.series,runtime.rows,runtime.options);
|
||||
renderLegend(document.getElementById(`${id}Legend`),runtime.series);
|
||||
const runtime = chartRuntime.get(id);
|
||||
const canvas = document.getElementById(id);
|
||||
if (!runtime || !canvas) return;
|
||||
canvas.dataset.chartZoom = String(clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM));
|
||||
drawLineChart(canvas, runtime.series, runtime.rows, runtime.options);
|
||||
renderLegend(document.getElementById(`${id}Legend`), runtime.series);
|
||||
updateChartZoomControls(id);
|
||||
}
|
||||
|
||||
function setChartZoom(id, value, anchorRatio=null, anchorViewportX=null) {
|
||||
const canvas=document.getElementById(id);
|
||||
const wrap=canvas?.parentElement;
|
||||
if(!canvas || !wrap) 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,MAX_CHART_ZOOM);
|
||||
app.chartZooms[id]=next;
|
||||
function setChartZoom(id, value, anchorRatio = null, anchorViewportX = null) {
|
||||
const canvas = document.getElementById(id);
|
||||
const wrap = canvas?.parentElement;
|
||||
if (!canvas || !wrap) 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, MAX_CHART_ZOOM);
|
||||
app.chartZooms[id] = next;
|
||||
redrawHistoryChart(id);
|
||||
const nextCanvas=document.getElementById(id);
|
||||
const nextWrap=nextCanvas?.parentElement;
|
||||
if(!nextCanvas || !nextWrap) return;
|
||||
const nextWidth=nextCanvas.getBoundingClientRect().width || 1;
|
||||
const previousBehavior=nextWrap.style.scrollBehavior;
|
||||
nextWrap.style.scrollBehavior='auto';
|
||||
nextWrap.scrollLeft=Math.max(0,ratio*nextWidth-viewportX);
|
||||
nextWrap.style.scrollBehavior=previousBehavior;
|
||||
const nextCanvas = document.getElementById(id);
|
||||
const nextWrap = nextCanvas?.parentElement;
|
||||
if (!nextCanvas || !nextWrap) return;
|
||||
const nextWidth = nextCanvas.getBoundingClientRect().width || 1;
|
||||
const previousBehavior = nextWrap.style.scrollBehavior;
|
||||
nextWrap.style.scrollBehavior = 'auto';
|
||||
nextWrap.scrollLeft = Math.max(0, ratio * nextWidth - viewportX);
|
||||
nextWrap.style.scrollBehavior = previousBehavior;
|
||||
}
|
||||
|
||||
function cssColor(name, fallback) {
|
||||
@@ -130,7 +130,7 @@ function cssColor(name, fallback) {
|
||||
}
|
||||
|
||||
function timeLabel(ts, hours) {
|
||||
const options = Number(hours) > 48 ? {day:'2-digit', month:'2-digit', hour:'2-digit'} : {hour:'2-digit', minute:'2-digit'};
|
||||
const options = Number(hours) > 48 ? { day: '2-digit', month: '2-digit', hour: '2-digit' } : { hour: '2-digit', minute: '2-digit' };
|
||||
return new Date(ts).toLocaleString(locale(), options);
|
||||
}
|
||||
|
||||
@@ -142,157 +142,157 @@ function prepareCanvas(canvas, height) {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = width * dpr; canvas.height = height * dpr;
|
||||
canvas.style.width = `${width}px`; canvas.style.height = `${height}px`;
|
||||
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr,0,0,dpr,0,0);
|
||||
ctx.clearRect(0,0,width,height);
|
||||
return {ctx,width,height};
|
||||
const ctx = canvas.getContext('2d'); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
return { ctx, width, height };
|
||||
}
|
||||
|
||||
function drawEmptyChart(canvas, height=340) {
|
||||
function drawEmptyChart(canvas, height = 340) {
|
||||
if (!canvas) return;
|
||||
const {ctx,width} = prepareCanvas(canvas,height);
|
||||
ctx.fillStyle = cssColor('--muted','#888'); ctx.font = '13px system-ui'; ctx.textAlign='center';
|
||||
ctx.fillText(tr('history.noData'), 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; canvas.onpointerdown=null; canvas.onpointerup=null; canvas.onpointercancel=null; canvas.onkeydown=null;
|
||||
const { ctx, width } = prepareCanvas(canvas, height);
|
||||
ctx.fillStyle = cssColor('--muted', '#888'); ctx.font = '13px system-ui'; ctx.textAlign = 'center';
|
||||
ctx.fillText(tr('history.noData'), 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; canvas.onpointerdown = null; canvas.onpointerup = null; canvas.onpointercancel = null; canvas.onkeydown = null;
|
||||
}
|
||||
|
||||
function chartPreciseTime(timestamp) {
|
||||
return new Intl.DateTimeFormat(locale(), {
|
||||
year:'numeric', month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit', second:'2-digit'
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit'
|
||||
}).format(new Date(timestamp));
|
||||
}
|
||||
|
||||
function nearestPoint(points, targetTs) {
|
||||
if (!points.length) return null;
|
||||
let lo=0, hi=points.length-1;
|
||||
while(lo<hi){
|
||||
const mid=Math.floor((lo+hi)/2);
|
||||
if(points[mid].ts < targetTs) lo=mid+1; else hi=mid;
|
||||
let lo = 0, hi = points.length - 1;
|
||||
while (lo < hi) {
|
||||
const mid = Math.floor((lo + hi) / 2);
|
||||
if (points[mid].ts < targetTs) lo = mid + 1; else hi = mid;
|
||||
}
|
||||
const after=points[lo];
|
||||
const before=lo>0?points[lo-1]:null;
|
||||
if(!before) return after;
|
||||
return Math.abs(before.ts-targetTs) <= Math.abs(after.ts-targetTs) ? before : after;
|
||||
const after = points[lo];
|
||||
const before = lo > 0 ? points[lo - 1] : null;
|
||||
if (!before) return after;
|
||||
return Math.abs(before.ts - targetTs) <= Math.abs(after.ts - targetTs) ? before : after;
|
||||
}
|
||||
|
||||
function formatChartTooltipValue(item, point, binaryLabels) {
|
||||
if (!point || !Number.isFinite(point.value)) return '—';
|
||||
if (typeof item.tooltipValue === 'function') return String(item.tooltipValue(point.value, point.row));
|
||||
const formatted = Number(point.value).toLocaleString(locale(), {minimumFractionDigits:1, maximumFractionDigits:2});
|
||||
const formatted = Number(point.value).toLocaleString(locale(), { minimumFractionDigits: 1, maximumFractionDigits: 2 });
|
||||
return binaryLabels ? formatted : `${formatted} °C`;
|
||||
}
|
||||
|
||||
function bindChartTooltip(canvas, series, sortedRows, geometry) {
|
||||
const wrap=canvas?.parentElement;
|
||||
const tooltip=wrap?.querySelector('.chart-tooltip');
|
||||
const line=wrap?.querySelector('.chart-hover-line');
|
||||
const selection=wrap?.querySelector('.chart-selection');
|
||||
if(!canvas || !wrap || !tooltip || !line) return;
|
||||
const wrap = canvas?.parentElement;
|
||||
const tooltip = wrap?.querySelector('.chart-tooltip');
|
||||
const line = wrap?.querySelector('.chart-hover-line');
|
||||
const selection = wrap?.querySelector('.chart-selection');
|
||||
if (!canvas || !wrap || !tooltip || !line) return;
|
||||
|
||||
const {pad,width,height,firstTs,lastTs,binaryLabels}=geometry;
|
||||
const plotWidth=width-pad.left-pad.right;
|
||||
const timestamps=[...new Set(sortedRows.map(row=>new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a,b)=>a-b);
|
||||
const timestampPoints=timestamps.map(ts=>({ts,value:ts}));
|
||||
const pointsBySeries=series.map(item=>({
|
||||
const { pad, width, height, firstTs, lastTs, binaryLabels } = geometry;
|
||||
const plotWidth = width - pad.left - pad.right;
|
||||
const timestamps = [...new Set(sortedRows.map(row => new Date(row.timestamp).getTime()).filter(Number.isFinite))].sort((a, b) => a - b);
|
||||
const timestampPoints = timestamps.map(ts => ({ ts, value: ts }));
|
||||
const pointsBySeries = series.map(item => ({
|
||||
item,
|
||||
points:sortedRows.map(row=>({row,ts:new Date(row.timestamp).getTime(),value:item.value(row)}))
|
||||
.filter(point=>Number.isFinite(point.ts)&&Number.isFinite(point.value))
|
||||
.sort((a,b)=>a.ts-b.ts)
|
||||
points: sortedRows.map(row => ({ row, ts: new Date(row.timestamp).getTime(), value: item.value(row) }))
|
||||
.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 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; };
|
||||
const canvasX=clientX=>{
|
||||
const rect=canvas.getBoundingClientRect();
|
||||
return clamp(clientX-rect.left,pad.left,width-pad.right);
|
||||
const hide = () => { tooltip.hidden = true; line.hidden = true; };
|
||||
const hideSelection = () => { if (selection) selection.hidden = true; };
|
||||
const canvasX = clientX => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return clamp(clientX - rect.left, pad.left, width - pad.right);
|
||||
};
|
||||
const showAtTimestamp=(targetTs)=>{
|
||||
if(!timestamps.length) return hide();
|
||||
const snapped=nearestPoint(timestampPoints,targetTs)?.ts;
|
||||
if(!Number.isFinite(snapped)) return hide();
|
||||
const px=pad.left + (snapped-firstTs)/Math.max(1,lastTs-firstTs)*plotWidth;
|
||||
const values=pointsBySeries.map(({item,points})=>({item,point:nearestPoint(points,snapped)})).filter(entry=>entry.point);
|
||||
if(!values.length) return hide();
|
||||
const showAtTimestamp = (targetTs) => {
|
||||
if (!timestamps.length) return hide();
|
||||
const snapped = nearestPoint(timestampPoints, targetTs)?.ts;
|
||||
if (!Number.isFinite(snapped)) return hide();
|
||||
const px = pad.left + (snapped - firstTs) / Math.max(1, lastTs - firstTs) * plotWidth;
|
||||
const values = pointsBySeries.map(({ item, points }) => ({ item, point: nearestPoint(points, snapped) })).filter(entry => entry.point);
|
||||
if (!values.length) return hide();
|
||||
|
||||
$$('.chart-tooltip').forEach(node=>{ if(node!==tooltip) node.hidden=true; });
|
||||
$$('.chart-hover-line').forEach(node=>{ if(node!==line) node.hidden=true; });
|
||||
tooltip.innerHTML=`<strong class="chart-tooltip-time">${esc(chartPreciseTime(snapped))}</strong><div class="chart-tooltip-values">${values.map(({item,point})=>{
|
||||
const sampleTime=Math.abs(point.ts-snapped)>1000?`<small class="chart-tooltip-sample-time">${esc(chartPreciseTime(point.ts))}</small>`:'';
|
||||
return `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(formatChartTooltipValue(item,point,binaryLabels))}${sampleTime}</span></div>`;
|
||||
$$('.chart-tooltip').forEach(node => { if (node !== tooltip) node.hidden = true; });
|
||||
$$('.chart-hover-line').forEach(node => { if (node !== line) node.hidden = true; });
|
||||
tooltip.innerHTML = `<strong class="chart-tooltip-time">${esc(chartPreciseTime(snapped))}</strong><div class="chart-tooltip-values">${values.map(({ item, point }) => {
|
||||
const sampleTime = Math.abs(point.ts - snapped) > 1000 ? `<small class="chart-tooltip-sample-time">${esc(chartPreciseTime(point.ts))}</small>` : '';
|
||||
return `<div class="chart-tooltip-row"><span class="chart-tooltip-label"><i style="--tooltip-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></span><span class="chart-tooltip-value">${esc(formatChartTooltipValue(item, point, binaryLabels))}${sampleTime}</span></div>`;
|
||||
}).join('')}</div>`;
|
||||
tooltip.hidden=false;
|
||||
line.hidden=false;
|
||||
line.style.left=`${px}px`;
|
||||
line.style.top=`${pad.top}px`;
|
||||
line.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`;
|
||||
tooltip.hidden = false;
|
||||
line.hidden = false;
|
||||
line.style.left = `${px}px`;
|
||||
line.style.top = `${pad.top}px`;
|
||||
line.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
|
||||
const minLeft=wrap.scrollLeft+8;
|
||||
const maxLeft=wrap.scrollLeft+wrap.clientWidth-tooltip.offsetWidth-8;
|
||||
let tooltipLeft=px+12;
|
||||
if(tooltipLeft+tooltip.offsetWidth>wrap.scrollLeft+wrap.clientWidth-8) tooltipLeft=px-tooltip.offsetWidth-12;
|
||||
tooltip.style.left=`${Math.max(minLeft,Math.min(tooltipLeft,Math.max(minLeft,maxLeft)))}px`;
|
||||
tooltip.style.top=`${pad.top+8}px`;
|
||||
canvas.dataset.chartHoverTs=String(snapped);
|
||||
const minLeft = wrap.scrollLeft + 8;
|
||||
const maxLeft = wrap.scrollLeft + wrap.clientWidth - tooltip.offsetWidth - 8;
|
||||
let tooltipLeft = px + 12;
|
||||
if (tooltipLeft + tooltip.offsetWidth > wrap.scrollLeft + wrap.clientWidth - 8) tooltipLeft = px - tooltip.offsetWidth - 12;
|
||||
tooltip.style.left = `${Math.max(minLeft, Math.min(tooltipLeft, Math.max(minLeft, maxLeft)))}px`;
|
||||
tooltip.style.top = `${pad.top + 8}px`;
|
||||
canvas.dataset.chartHoverTs = String(snapped);
|
||||
};
|
||||
|
||||
const showFromClientX=(clientX)=>{
|
||||
const localX=canvasX(clientX);
|
||||
const targetTs=firstTs + ((localX-pad.left)/Math.max(1,plotWidth))*(lastTs-firstTs);
|
||||
const showFromClientX = (clientX) => {
|
||||
const localX = canvasX(clientX);
|
||||
const targetTs = firstTs + ((localX - pad.left) / Math.max(1, plotWidth)) * (lastTs - firstTs);
|
||||
showAtTimestamp(targetTs);
|
||||
};
|
||||
|
||||
const updateSelection=(currentX)=>{
|
||||
if(!drag || !selection) return;
|
||||
const left=Math.min(drag.startX,currentX);
|
||||
const right=Math.max(drag.startX,currentX);
|
||||
drag.lastX=currentX;
|
||||
drag.moved=Math.abs(right-left)>=7;
|
||||
if(!drag.moved) return hideSelection();
|
||||
selection.hidden=false;
|
||||
selection.style.left=`${left}px`;
|
||||
selection.style.width=`${Math.max(1,right-left)}px`;
|
||||
selection.style.top=`${pad.top}px`;
|
||||
selection.style.height=`${Math.max(0,height-pad.top-pad.bottom)}px`;
|
||||
const updateSelection = (currentX) => {
|
||||
if (!drag || !selection) return;
|
||||
const left = Math.min(drag.startX, currentX);
|
||||
const right = Math.max(drag.startX, currentX);
|
||||
drag.lastX = currentX;
|
||||
drag.moved = Math.abs(right - left) >= 7;
|
||||
if (!drag.moved) return hideSelection();
|
||||
selection.hidden = false;
|
||||
selection.style.left = `${left}px`;
|
||||
selection.style.width = `${Math.max(1, right - left)}px`;
|
||||
selection.style.top = `${pad.top}px`;
|
||||
selection.style.height = `${Math.max(0, height - pad.top - pad.bottom)}px`;
|
||||
};
|
||||
|
||||
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};
|
||||
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};
|
||||
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),
|
||||
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();
|
||||
@@ -300,31 +300,31 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
|
||||
}
|
||||
};
|
||||
|
||||
canvas.onpointermove=event=>{
|
||||
if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){
|
||||
canvas.onpointermove = event => {
|
||||
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)}%`;
|
||||
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;
|
||||
} 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);
|
||||
wrap.scrollLeft = Math.max(0, touchPan.scrollLeft - dx);
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -333,122 +333,122 @@ function bindChartTooltip(canvas, series, sortedRows, geometry) {
|
||||
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);
|
||||
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;
|
||||
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 (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;
|
||||
}
|
||||
};
|
||||
|
||||
canvas.onpointerup=finishPointer;
|
||||
canvas.onpointercancel=event=>{
|
||||
if(event.pointerType==='mouse' && drag?.pointerId===event.pointerId){ drag=null; hideSelection(); }
|
||||
if(event.pointerType==='touch'){
|
||||
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);
|
||||
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=>{
|
||||
if(!['ArrowLeft','ArrowRight'].includes(event.key) || !timestamps.length) return;
|
||||
canvas.onpointerleave = event => { if (event.pointerType !== 'touch' && !drag) hide(); };
|
||||
canvas.onkeydown = event => {
|
||||
if (!['ArrowLeft', 'ArrowRight'].includes(event.key) || !timestamps.length) return;
|
||||
event.preventDefault();
|
||||
const current=Number(canvas.dataset.chartHoverTs);
|
||||
let index=Number.isFinite(current)?timestamps.findIndex(ts=>ts===current):-1;
|
||||
if(index<0) index=event.key==='ArrowRight'?0:timestamps.length-1;
|
||||
else index=clamp(index+(event.key==='ArrowRight'?1:-1),0,timestamps.length-1);
|
||||
const current = Number(canvas.dataset.chartHoverTs);
|
||||
let index = Number.isFinite(current) ? timestamps.findIndex(ts => ts === current) : -1;
|
||||
if (index < 0) index = event.key === 'ArrowRight' ? 0 : timestamps.length - 1;
|
||||
else index = clamp(index + (event.key === 'ArrowRight' ? 1 : -1), 0, timestamps.length - 1);
|
||||
showAtTimestamp(timestamps[index]);
|
||||
};
|
||||
}
|
||||
|
||||
function drawLineChart(canvas, series, rows, {height=340, minValue=null, maxValue=null, binaryLabels=false}={}) {
|
||||
function drawLineChart(canvas, series, rows, { height = 340, minValue = null, maxValue = null, binaryLabels = false } = {}) {
|
||||
if (!canvas) return;
|
||||
const options={height,minValue,maxValue,binaryLabels};
|
||||
if(canvas.id) chartRuntime.set(canvas.id,{series,rows,options});
|
||||
const visibleSeries=series.filter((item,index)=>!isChartSeriesHidden(canvas.id,item,index));
|
||||
const options = { height, minValue, maxValue, binaryLabels };
|
||||
if (canvas.id) chartRuntime.set(canvas.id, { series, rows, options });
|
||||
const visibleSeries = series.filter((item, index) => !isChartSeriesHidden(canvas.id, item, index));
|
||||
if (!rows.length || !visibleSeries.length) return drawEmptyChart(canvas, height);
|
||||
const sortedRows = [...rows].sort((a,b)=>new Date(a.timestamp)-new Date(b.timestamp));
|
||||
const {ctx,width} = prepareCanvas(canvas,height);
|
||||
const text=cssColor('--muted','#888'), grid=cssColor('--grid','#333');
|
||||
const pad={left:54,right:20,top:20,bottom:42};
|
||||
const allValues=[];
|
||||
visibleSeries.forEach(item => sortedRows.forEach(row => { const value=item.value(row); if(Number.isFinite(value)) allValues.push(value); }));
|
||||
if (!allValues.length) return drawEmptyChart(canvas,height);
|
||||
let min = minValue == null ? Math.floor(Math.min(...allValues)-1) : minValue;
|
||||
let max = maxValue == null ? Math.ceil(Math.max(...allValues)+1) : maxValue;
|
||||
if (max-min < 2) { min-=1; max+=1; }
|
||||
const sortedRows = [...rows].sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
const { ctx, width } = prepareCanvas(canvas, height);
|
||||
const text = cssColor('--muted', '#888'), grid = cssColor('--grid', '#333');
|
||||
const pad = { left: 54, right: 20, top: 20, bottom: 42 };
|
||||
const allValues = [];
|
||||
visibleSeries.forEach(item => sortedRows.forEach(row => { const value = item.value(row); if (Number.isFinite(value)) allValues.push(value); }));
|
||||
if (!allValues.length) return drawEmptyChart(canvas, height);
|
||||
let min = minValue == null ? Math.floor(Math.min(...allValues) - 1) : minValue;
|
||||
let max = maxValue == null ? Math.ceil(Math.max(...allValues) + 1) : maxValue;
|
||||
if (max - min < 2) { min -= 1; max += 1; }
|
||||
const firstTs = new Date(sortedRows[0].timestamp).getTime();
|
||||
const lastTs = new Date(sortedRows[sortedRows.length-1].timestamp).getTime();
|
||||
const span = Math.max(1,lastTs-firstTs);
|
||||
const x = row => pad.left + (new Date(row.timestamp).getTime()-firstTs)/span*(width-pad.left-pad.right);
|
||||
const y = value => pad.top + (max-value)/(max-min)*(height-pad.top-pad.bottom);
|
||||
ctx.font='10px system-ui'; ctx.fillStyle=text; ctx.strokeStyle=grid; ctx.lineWidth=1;
|
||||
for(let i=0;i<=5;i++){
|
||||
const value=min+(max-min)*i/5, py=y(value);
|
||||
ctx.beginPath();ctx.moveTo(pad.left,py);ctx.lineTo(width-pad.right,py);ctx.stroke();
|
||||
ctx.textAlign='right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`,pad.left-8,py+3);
|
||||
const lastTs = new Date(sortedRows[sortedRows.length - 1].timestamp).getTime();
|
||||
const span = Math.max(1, lastTs - firstTs);
|
||||
const x = row => pad.left + (new Date(row.timestamp).getTime() - firstTs) / span * (width - pad.left - pad.right);
|
||||
const y = value => pad.top + (max - value) / (max - min) * (height - pad.top - pad.bottom);
|
||||
ctx.font = '10px system-ui'; ctx.fillStyle = text; ctx.strokeStyle = grid; ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 5; i++) {
|
||||
const value = min + (max - min) * i / 5, py = y(value);
|
||||
ctx.beginPath(); ctx.moveTo(pad.left, py); ctx.lineTo(width - pad.right, py); ctx.stroke();
|
||||
ctx.textAlign = 'right'; ctx.fillText(binaryLabels ? value.toFixed(0) : `${value.toFixed(1)}°`, pad.left - 8, py + 3);
|
||||
}
|
||||
const ticks=5;
|
||||
for(let i=0;i<=ticks;i++){
|
||||
const idx=Math.min(sortedRows.length-1,Math.round(i*(sortedRows.length-1)/ticks)); const px=x(sortedRows[idx]);
|
||||
ctx.textAlign='center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp,$('#historyHours')?.value),px,height-14);
|
||||
const ticks = 5;
|
||||
for (let i = 0; i <= ticks; i++) {
|
||||
const idx = Math.min(sortedRows.length - 1, Math.round(i * (sortedRows.length - 1) / ticks)); const px = x(sortedRows[idx]);
|
||||
ctx.textAlign = 'center'; ctx.fillText(timeLabel(sortedRows[idx].timestamp, $('#historyHours')?.value), px, height - 14);
|
||||
}
|
||||
visibleSeries.forEach(item => {
|
||||
ctx.beginPath(); ctx.strokeStyle=item.color; ctx.lineWidth=item.width||2; ctx.setLineDash(item.dash||[]);
|
||||
let started=false, prev=null;
|
||||
ctx.beginPath(); ctx.strokeStyle = item.color; ctx.lineWidth = item.width || 2; ctx.setLineDash(item.dash || []);
|
||||
let started = false, prev = null;
|
||||
sortedRows.forEach(row => {
|
||||
const value=item.value(row); if(!Number.isFinite(value)) return;
|
||||
const px=x(row),py=y(value);
|
||||
if(!started||prev===null){ctx.moveTo(px,py);started=true;}
|
||||
else if(item.step){ctx.lineTo(px,y(prev));ctx.lineTo(px,py);} else ctx.lineTo(px,py);
|
||||
prev=value;
|
||||
const value = item.value(row); if (!Number.isFinite(value)) return;
|
||||
const px = x(row), py = y(value);
|
||||
if (!started || prev === null) { ctx.moveTo(px, py); started = true; }
|
||||
else if (item.step) { ctx.lineTo(px, y(prev)); ctx.lineTo(px, py); } else ctx.lineTo(px, py);
|
||||
prev = value;
|
||||
});
|
||||
ctx.stroke(); ctx.setLineDash([]);
|
||||
});
|
||||
bindChartTooltip(canvas,visibleSeries,sortedRows,{pad,width,height,firstTs,lastTs,binaryLabels});
|
||||
bindChartTooltip(canvas, visibleSeries, sortedRows, { pad, width, height, firstTs, lastTs, binaryLabels });
|
||||
}
|
||||
|
||||
function renderLegend(host, series) {
|
||||
if (!host) return;
|
||||
const chartId=host.id.replace(/Legend$/,'');
|
||||
host.innerHTML=series.map((item,index)=>{
|
||||
const hidden=isChartSeriesHidden(chartId,item,index);
|
||||
const title=tr(hidden?'history.legendShow':'history.legendHide',{name:item.label});
|
||||
return `<button type="button" class="legend-item${hidden?' is-hidden':''}" data-chart-id="${esc(chartId)}" data-chart-legend="${index}" aria-pressed="${hidden?'false':'true'}" title="${esc(title)}"><i class="legend-line" style="--legend-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></button>`;
|
||||
const chartId = host.id.replace(/Legend$/, '');
|
||||
host.innerHTML = series.map((item, index) => {
|
||||
const hidden = isChartSeriesHidden(chartId, item, index);
|
||||
const title = tr(hidden ? 'history.legendShow' : 'history.legendHide', { name: item.label });
|
||||
return `<button type="button" class="legend-item${hidden ? ' is-hidden' : ''}" data-chart-id="${esc(chartId)}" data-chart-legend="${index}" aria-pressed="${hidden ? 'false' : 'true'}" title="${esc(title)}"><i class="legend-line" style="--legend-color:${esc(item.color)}"></i><span>${esc(item.label)}</span></button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function historyChartMarkup(id, title, hint, compact=false) {
|
||||
function historyChartMarkup(id, title, hint, compact = false) {
|
||||
const zoom = clamp(Number(app.chartZooms[id] || 1), 1, MAX_CHART_ZOOM);
|
||||
const percent = Math.round(zoom * 100);
|
||||
return `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom<=1?'disabled':''}>−</button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom>=MAX_CHART_ZOOM?'disabled':''}>+</button></div></div><div class="chart-wrap ${compact?'compact-chart':''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact?300:420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
|
||||
return `<div class="panel chart-panel history-chart-card"><div class="chart-title-row"><div><h3>${esc(title)}</h3><p>${esc(hint || '')}</p><small class="chart-hover-hint">${esc(tr('history.hoverHint'))}</small></div><div class="chart-zoom-controls" aria-label="${esc(tr('history.zoomControls'))}"><button type="button" data-chart-zoom="out" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomOut'))}" ${zoom <= 1 ? 'disabled' : ''}>−</button><button type="button" class="chart-zoom-reset" data-chart-zoom="reset" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomReset'))}">${percent}%</button><button type="button" data-chart-zoom="in" data-chart-id="${esc(id)}" title="${esc(tr('history.zoomIn'))}" ${zoom >= MAX_CHART_ZOOM ? 'disabled' : ''}>+</button></div></div><div class="chart-wrap ${compact ? 'compact-chart' : ''}"><canvas id="${esc(id)}" data-chart-zoom="${zoom}" width="1000" height="${compact ? 300 : 420}" tabindex="0" aria-label="${esc(title)}"></canvas><div class="chart-hover-line" hidden></div><div class="chart-selection" hidden></div><div class="chart-tooltip" hidden></div></div><div class="legend" id="${esc(id)}Legend"></div></div>`;
|
||||
}
|
||||
|
||||
function historySeriesColor(index) {
|
||||
return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index*67)%360} 68% 55%)`);
|
||||
return cssColor(HISTORY_COLORS[index % HISTORY_COLORS.length], `hsl(${(index * 67) % 360} 68% 55%)`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user