This commit is contained in:
Mateusz Gruszczyński committed 2026-09-23 13:41:34 +02:00
1 parent abfbf0dd68
commit aa1cb05b2f
24 files changed
+750 -71

No files matched your search

+143 -7
View File
@@ -10,6 +10,7 @@ const FLOW_NODE_META = Object.freeze({
delay: { titleKey: 'flow.node.delay', category: 'timeop' },
rolling_stat: { titleKey: 'flow.node.rollingStat', category: 'sensor' },
oscillates: { titleKey: 'flow.node.oscillates', category: 'sensor' },
heating_curve: { titleKey: 'flow.node.heatingCurve', category: 'sensor' },
outdoor_temperature: { titleKey: 'flow.node.outdoorTemperature', category: 'sensor' },
device_temperature: { titleKey: 'flow.node.deviceTemperature', category: 'sensor' },
zone_temperature: { titleKey: 'flow.node.zoneTemperature', category: 'sensor' },
@@ -82,6 +83,7 @@ function flowDefaultConfig(kind) {
if (kind === 'delay') return { seconds: 30 };
if (kind === 'rolling_stat') return { source: 'outdoor_temperature', statistic: 'mean', window_seconds: 300, operator: 'lt', value: 20, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' };
if (kind === 'oscillates') return { source: 'outdoor_temperature', window_seconds: 300, min_span: 1.0, min_direction_changes: 2, device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '' };
if (kind === 'heating_curve') return { source: 'outdoor_temperature', device_id: app.devices[0]?.id || '', zone_id: app.zones[0]?.id || '', entity_id: '', points: [{ x: -20, y: 30 }, { x: -10, y: 28 }, { x: 0, y: 25 }, { x: 10, y: 22 }, { x: 20, y: 19 }] };
if (kind === 'outdoor_temperature') return { operator: 'lt', value: 5 };
if (kind === 'device_temperature') return { device_id: app.devices[0]?.id || '', operator: 'lt', value: 20 };
if (kind === 'zone_temperature') return { zone_id: app.zones[0]?.id || '', operator: 'lt', value: 20 };
@@ -385,6 +387,11 @@ function flowNodeSummary(node) {
if (node.kind === 'delay') return flowDurationLabel(c.seconds);
if (node.kind === 'rolling_stat') return `${c.statistic === 'median' ? tr('flow.median') : tr('flow.mean')} · ${flowDurationLabel(c.window_seconds)} · ${flowOperatorLabel(c.operator)} ${c.value ?? '—'}`;
if (node.kind === 'oscillates') return `${flowDurationLabel(c.window_seconds)} · Δ≥${Number(c.min_span ?? 1)} · ↕≥${Number(c.min_direction_changes ?? 2)}`;
if (node.kind === 'heating_curve') {
const points = flowHeatingCurvePoints(c); const first = points[0], last = points[points.length - 1];
const source = flowHeatingCurveSourceLabel(c);
return points.length >= 2 ? `${source} · ${first.x}°→${first.y}° / ${last.x}°→${last.y}°` : source;
}
if (node.kind === 'outdoor_temperature') return `${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
if (node.kind === 'device_temperature') return `${app.devices.find(d => d.id === c.device_id)?.name || tr('common.noDevice')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
if (node.kind === 'zone_temperature') return `${app.zones.find(z => z.id === c.zone_id)?.name || tr('common.noZone')} · ${flowOperatorLabel(c.operator)} ${fmtTemp(c.value)}`;
@@ -692,6 +699,80 @@ function sharedFlowReferenceComparisonFields(item, config) {
return `<p class="field-note">${esc(tr('flow.sharedInputFlowComparisonHint'))}</p><div class="two"><label><span>${esc(tr('flow.operator'))}</span><select data-flow-config="operator">${operatorOptions}</select></label><label><span>${esc(tr('flow.value'))}</span>${valueField}</label></div>`;
}
function flowHeatingCurvePoints(config) {
return (Array.isArray(config?.points) ? config.points : [])
.map(point => ({ x: Number(point?.x), y: Number(point?.y) }))
.filter(point => Number.isFinite(point.x) && Number.isFinite(point.y))
.sort((a, b) => a.x - b.x);
}
function flowHeatingCurveSourceLabel(config) {
const source = config?.source || 'outdoor_temperature';
if (source === 'device_temperature') return app.devices.find(item => item.id === config.device_id)?.name || tr('flow.node.deviceTemperature');
if (source === 'zone_temperature') return app.zones.find(item => item.id === config.zone_id)?.name || tr('flow.node.zoneTemperature');
if (source === 'ha_numeric') return config.entity_id ? haSensorLabel(config.entity_id) : tr('flow.node.haNumeric');
return tr('flow.node.outdoorTemperature');
}
function flowHeatingCurveInterpolate(points, x) {
if (!points.length) return 0;
if (x <= points[0].x) return points[0].y;
if (x >= points[points.length - 1].x) return points[points.length - 1].y;
for (let index = 0; index < points.length - 1; index += 1) {
const a = points[index], b = points[index + 1];
if (x < a.x || x > b.x || a.x === b.x) continue;
return a.y + ((x - a.x) / (b.x - a.x)) * (b.y - a.y);
}
return points[0].y;
}
function flowHeatingCurveChart(config) {
const points = flowHeatingCurvePoints(config);
if (points.length < 2) return `<div class="empty compact"><span>${esc(tr('flow.heatingCurveNeedsPoints'))}</span></div>`;
const width = 360, height = 220, left = 46, right = 18, top = 16, bottom = 38;
const plotW = width - left - right, plotH = height - top - bottom;
const xs = points.map(point => point.x), ys = points.map(point => point.y);
let minX = Math.min(...xs), maxX = Math.max(...xs), minY = Math.min(...ys), maxY = Math.max(...ys);
if (minX === maxX) { minX -= 1; maxX += 1; }
if (minY === maxY) { minY -= 1; maxY += 1; }
const xPad = Math.max(1, (maxX - minX) * .08), yPad = Math.max(1, (maxY - minY) * .12);
minX -= xPad; maxX += xPad; minY -= yPad; maxY += yPad;
const sx = value => left + ((value - minX) / (maxX - minX)) * plotW;
const sy = value => top + plotH - ((value - minY) / (maxY - minY)) * plotH;
const polyline = points.map(point => `${sx(point.x).toFixed(1)},${sy(point.y).toFixed(1)}`).join(' ');
const circles = points.map((point, index) => `<circle cx="${sx(point.x).toFixed(1)}" cy="${sy(point.y).toFixed(1)}" r="5" data-flow-curve-chart-point="${index}"><title>${esc(`${point.x}°C → ${point.y}°C`)}</title></circle>`).join('');
const xMid = (minX + maxX) / 2, yMid = (minY + maxY) / 2;
return `<div class="flow-heating-curve-chart"><svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${esc(tr('flow.heatingCurveChartLabel'))}">
<line class="axis" x1="${left}" y1="${top}" x2="${left}" y2="${top + plotH}"/><line class="axis" x1="${left}" y1="${top + plotH}" x2="${left + plotW}" y2="${top + plotH}"/>
<line class="grid" x1="${sx(xMid)}" y1="${top}" x2="${sx(xMid)}" y2="${top + plotH}"/><line class="grid" x1="${left}" y1="${sy(yMid)}" x2="${left + plotW}" y2="${sy(yMid)}"/>
<polyline class="curve" points="${polyline}"/>${circles}
<text x="${left}" y="${height - 18}" text-anchor="middle">${Number(minX.toFixed(1))}</text><text x="${left + plotW}" y="${height - 18}" text-anchor="middle">${Number(maxX.toFixed(1))}</text>
<text x="${left - 8}" y="${top + 4}" text-anchor="end">${Number(maxY.toFixed(1))}</text><text x="${left - 8}" y="${top + plotH + 4}" text-anchor="end">${Number(minY.toFixed(1))}</text>
<text class="axis-title" x="${left + plotW / 2}" y="${height - 3}" text-anchor="middle">${esc(tr('flow.heatingCurveAxisX'))}</text>
<text class="axis-title" transform="translate(12 ${top + plotH / 2}) rotate(-90)" text-anchor="middle">${esc(tr('flow.heatingCurveAxisY'))}</text>
</svg></div>`;
}
function flowHeatingCurveEditor(config) {
const source = config.source || 'outdoor_temperature';
const points = flowHeatingCurvePoints(config);
const sourceFields = source === 'device_temperature'
? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, config.device_id)}</select></label>`
: source === 'zone_temperature'
? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, config.zone_id)}</select></label>`
: source === 'ha_numeric'
? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(config.entity_id || '')}" placeholder="sensor.outdoor_temperature"></label>`
: '';
const rows = points.map((point, index) => `<div class="flow-heating-curve-point-row"><label><span>X</span><input type="number" step="0.1" data-flow-curve-point="x" data-flow-curve-index="${index}" value="${point.x}"></label><label><span>Y</span><input type="number" step="0.1" data-flow-curve-point="y" data-flow-curve-index="${index}" value="${point.y}"></label><button type="button" class="secondary" data-flow-curve-remove="${index}" ${points.length <= 2 ? 'disabled' : ''} aria-label="${esc(tr('actions.delete'))}">${uiIcon('close')}</button></div>`).join('');
return `<label><span>${esc(tr('flow.heatingCurveSource'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([value, label]) => `<option value="${value}" ${source === value ? 'selected' : ''}>${esc(label)}</option>`).join('')}</select></label>${sourceFields}${flowHeatingCurveChart(config)}<div class="flow-heating-curve-points-head"><strong>${esc(tr('flow.heatingCurvePoints'))}</strong><button type="button" class="secondary" data-flow-curve-add ${points.length >= 32 ? 'disabled' : ''}>${esc(tr('flow.heatingCurveAddPoint'))}</button></div><div class="flow-heating-curve-points">${rows}</div><p class="field-note">${esc(tr('flow.heatingCurveHint'))}</p>`;
}
function flowIncomingHeatingCurve(nodeId) {
if (!app.flowDraft) return null;
const edge = app.flowDraft.edges.find(item => item.to === nodeId && flowNodeById(item.from)?.kind === 'heating_curve');
return edge ? flowNodeById(edge.from) : null;
}
function renderFlowInspector() {
const host = $('#flowInspector'), node = flowNodeById(app.flowSelectedNodeId); if (!host) return;
host.classList.toggle('has-selection', Boolean(node));
@@ -709,6 +790,7 @@ function renderFlowInspector() {
else if (node.kind === 'delay') fields = `<label><span>${esc(tr('flow.durationSeconds'))}</span><input type="number" min="1" max="604800" data-flow-config="seconds" value="${Number(c.seconds || 30)}"></label><p class="field-note">${esc(tr('flow.delayHint'))}</p>`;
else if (node.kind === 'rolling_stat') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.statistic'))}</span><select data-flow-config="statistic"><option value="mean" ${c.statistic !== 'median' ? 'selected' : ''}>${esc(tr('flow.mean'))}</option><option value="median" ${c.statistic === 'median' ? 'selected' : ''}>${esc(tr('flow.median'))}</option></select></label><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label></div>${flowComparisonFields(c, false)}`; }
else if (node.kind === 'oscillates') { const source = c.source || 'outdoor_temperature'; fields = `<label><span>${esc(tr('flow.source'))}</span><select data-flow-config="source">${[['outdoor_temperature', tr('flow.node.outdoorTemperature')], ['device_temperature', tr('flow.node.deviceTemperature')], ['zone_temperature', tr('flow.node.zoneTemperature')], ['ha_numeric', tr('flow.node.haNumeric')]].map(([v, l]) => `<option value="${v}" ${source === v ? 'selected' : ''}>${esc(l)}</option>`).join('')}</select></label>${source === 'device_temperature' ? `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>` : ''}${source === 'zone_temperature' ? `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>` : ''}${source === 'ha_numeric' ? `<label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="sensor.temperature"></label>` : ''}<div class="two"><label><span>${esc(tr('flow.windowSeconds'))}</span><input type="number" min="10" max="604800" data-flow-config="window_seconds" value="${Number(c.window_seconds || 300)}"></label><label><span>${esc(tr('flow.minSpan'))}</span><input type="number" min="0.001" step="0.1" data-flow-config="min_span" value="${Number(c.min_span ?? 1)}"></label></div><label><span>${esc(tr('flow.minDirectionChanges'))}</span><input type="number" min="1" max="1000" data-flow-config="min_direction_changes" value="${Number(c.min_direction_changes || 2)}"></label><p class="field-note">${esc(tr('flow.oscillatesHint'))}</p>`; }
else if (node.kind === 'heating_curve') fields = flowHeatingCurveEditor(c);
else if (node.kind === 'outdoor_temperature') fields = flowComparisonFields(c);
else if (node.kind === 'device_temperature') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label>${flowComparisonFields(c)}`;
else if (node.kind === 'zone_temperature') fields = `<label><span>${esc(tr('common.zone'))}</span><select data-flow-config="zone_id">${flowSelectOptions(app.zones, c.zone_id)}</select></label>${flowComparisonFields(c)}`;
@@ -736,6 +818,14 @@ function renderFlowInspector() {
else if (node.kind === 'device_feature_action') fields = `<label><span>${esc(tr('common.device'))}</span><select data-flow-config="device_id">${flowSelectOptions(app.devices, c.device_id)}</select></label><label><span>${esc(tr('flow.deviceFeature'))}</span><select data-flow-config="feature">${flowDeviceFeatureOptions(c.feature)}</select></label>${flowDeviceFeatureValueField(c)}<p class="field-note">${esc(tr('flow.deviceFeatureHint'))}</p><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
else if (node.kind === 'group_action') fields = `<label><span>${esc(tr('groups.group'))}</span><select data-flow-config="group_id">${flowSelectOptions(app.groups, c.group_id)}</select></label>${flowActionFields(c, true)}`;
else if (node.kind === 'ha_service_action') fields = `<div class="two"><label><span>domain</span><input data-flow-config="domain" value="${esc(c.domain || 'switch')}" placeholder="switch"></label><label><span>service</span><input data-flow-config="service" value="${esc(c.service || 'turn_on')}" placeholder="turn_on"></label></div><p class="field-note">${esc(tr('flow.serviceExample'))}</p><label><span>entity_id</span><input data-flow-config="entity_id" list="haEntitySuggestions" value="${esc(c.entity_id || '')}" placeholder="switch.gas"></label><label><span>${esc(tr('flow.serviceData'))}</span><textarea rows="5" data-flow-config="data_json">${esc(JSON.stringify(c.data || {}, null, 2))}</textarea></label><label><span>${esc(tr('common.cooldownSeconds'))}</span><input type="number" min="30" data-flow-config="cooldown_seconds" value="${Number(c.cooldown_seconds || 60)}"></label>`;
const incomingCurve = flowIncomingHeatingCurve(node.id);
if (incomingCurve && ['zone_thermostat', 'device_action', 'group_action'].includes(node.kind)) {
fields += `<p class="field-note flow-value-binding-note">${esc(tr('flow.heatingCurveNativeBinding', { curve: flowNodeTitle(FLOW_NODE_META.heating_curve) }))}</p>`;
} else if (incomingCurve && node.kind === 'device_feature_action') {
fields += `<p class="field-note flow-value-binding-note">${esc(c.feature === 'target_temperature' ? tr('flow.heatingCurveNativeBinding', { curve: flowNodeTitle(FLOW_NODE_META.heating_curve) }) : tr('flow.heatingCurveFeatureWarning'))}</p>`;
} else if (incomingCurve && node.kind === 'ha_service_action') {
fields += `<label><span>${esc(tr('flow.heatingCurveHaValueKey'))}</span><input data-flow-config="value_key" value="${esc(c.value_key || 'temperature')}" placeholder="temperature"></label><p class="field-note flow-value-binding-note">${esc(tr('flow.heatingCurveHaBindingHint'))}</p>`;
}
host.innerHTML = `<div class="flow-inspector-head"><div><span class="eyebrow">${esc(tr('flow.blockSettings'))}</span><h3>${esc(flowNodeTitle(meta))}</h3></div><div class="flow-inspector-head-actions"><button type="button" class="secondary flow-inspector-expand" data-action="flow-toggle-inspector" aria-label="${esc(tr('flow.expandSettings'))}">${uiIcon('expand-vertical')}</button><button type="button" class="secondary flow-inspector-close" data-action="flow-clear-selection" aria-label="${esc(tr('flow.clearSelection'))}">${uiIcon('close')}</button><button type="button" class="danger flow-inspector-delete" data-flow-remove="${esc(node.id)}">${esc(tr('actions.delete'))}</button></div></div>${fields}<div class="flow-inspector-meta"><small>ID</small><code>${esc(node.id)}</code></div>`;
}
@@ -1224,7 +1314,7 @@ function localDateTimeInputValue(date = new Date()) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function flowSimulationOverrideNodes(flow = app.flowDraft) {
return (flow?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind));
return (flow?.nodes || []).filter(node => ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'heating_curve', 'ha_state', 'ha_numeric', 'ha_attribute', 'ha_available', 'house_mode', 'device_state', 'zone_state', 'group_state', 'night_mode', 'shared_input'].includes(node.kind));
}
function flowSimulationLouverAxis(node) {
if (node?.kind === 'device_state') {
@@ -1243,7 +1333,7 @@ function renderFlowSimulationOverrides() {
host.innerHTML = nodes.length ? nodes.map(node => {
const effectiveKind = node.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node.kind;
const louverAxis = flowSimulationLouverAxis(node);
const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind);
const numeric = ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(effectiveKind);
const control = louverAxis
? `<select data-flow-sim-node="${esc(node.id)}"><option value="">${esc(tr('flow.useLiveValue'))}</option>${louverOptions(louverAxis, null)}</select>`
: `<input ${numeric ? 'type="number" step="0.1"' : 'type="text"'} data-flow-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`;
@@ -1256,7 +1346,7 @@ function collectFlowSimulationOverrides() {
if (input.value.trim() === '') return;
const node = flowNodeById(input.dataset.flowSimNode); let value = input.value.trim();
const effectiveKind = node?.kind === 'shared_input' ? (app.flowSharedInputs || []).find(item => item.id === node.config?.input_id)?.kind : node?.kind;
if (node && (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(effectiveKind))) value = Number(value);
if (node && (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(effectiveKind))) value = Number(value);
else if (/^(true|false)$/i.test(value)) value = value.toLowerCase() === 'true';
result[input.dataset.flowSimNode] = value;
});
@@ -1283,7 +1373,7 @@ function flowSimpleSimulationControl(node) {
const louverAxis = flowSimulationLouverAxis(node);
const live = `<option value="">${esc(tr('flow.useLiveValue'))}</option>`;
if (louverAxis) return `<select data-flow-simple-sim-node="${esc(node.id)}">${live}${louverOptions(louverAxis, null)}</select>`;
if (['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) {
if (['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(source.kind)) {
return `<input type="number" step="0.1" data-flow-simple-sim-node="${esc(node.id)}" placeholder="${esc(tr('flow.useLiveValue'))}">`;
}
if (source.kind === 'house_mode') {
@@ -1309,7 +1399,7 @@ function collectFlowSimpleSimulationOverrides(flow) {
const node = byId.get(input.dataset.flowSimpleSimNode); if (!node) return;
const source = flowSimulationEffectiveSource(node);
let value = raw;
if (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric'].includes(source.kind)) value = Number(raw);
if (flowSimulationLouverAxis(node) || ['outdoor_temperature', 'device_temperature', 'zone_temperature', 'ha_numeric', 'heating_curve'].includes(source.kind)) value = Number(raw);
else if (/^(true|false)$/i.test(raw)) value = raw.toLowerCase() === 'true';
result[node.id] = value;
});
@@ -1320,6 +1410,10 @@ function flowSimpleSimulationActualText(node, actual) {
if (actual === null || actual === undefined) return tr('flow.simpleNoData');
if (typeof actual === 'object') {
if (actual.error) return tr('flow.simpleNoData');
if (node?.kind === 'heating_curve') {
const input = Number(actual.input), output = Number(actual.output);
if (Number.isFinite(input) && Number.isFinite(output)) return `${input.toFixed(1)} °C → ${output.toFixed(1)} °C`;
}
return '';
}
const source = flowSimulationEffectiveSource(node);
@@ -1361,7 +1455,8 @@ function renderFlowSimpleSimulationResult(result) {
return `<div class="flow-simple-condition ${item.matched ? 'pass' : 'fail'}"><span class="flow-simple-condition-mark">${uiIcon(item.matched ? 'check' : 'close')}</span><span><strong>${esc(flowNodeTitle(FLOW_NODE_META[node.kind] || { title: node.kind }))}</strong><small>${esc(flowNodeSummary(node))}</small></span>${actual ? `<em>${esc(tr('flow.simpleCurrentValue', { value: actual }))}</em>` : ''}</div>`;
}).join('');
const actionType = actionNode ? flowNodeTitle(FLOW_NODE_META[actionNode.kind] || { title: actionNode.kind }) : tr('flow.simpleEffect');
return `<article class="flow-simple-result ${state}"><div class="flow-simple-result-head"><div><small>${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}</small><strong>${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}</strong></div><span class="badge ${action.would_execute ? 'active' : ''}">${esc(stateLabel)}</span></div><p>${esc(explanation)}</p>${trace ? `<details class="flow-simple-details" ${action.would_execute ? '' : 'open'}><summary>${esc(tr('flow.simpleConditions'))}</summary><div class="flow-simple-condition-list">${trace}</div></details>` : ''}</article>`;
const dynamicValue = Number.isFinite(Number(action.dynamic_value)) ? `${Number(action.dynamic_value).toFixed(1)} °C → ${action.dynamic_value_target || 'target'}` : '';
return `<article class="flow-simple-result ${state}"><div class="flow-simple-result-head"><div><small>${esc(`${tr('flow.simpleEffect')} · ${actionType}`)}</small><strong>${esc(actionNode ? flowNodeSummary(actionNode) : action.node_id)}</strong></div><span class="badge ${action.would_execute ? 'active' : ''}">${esc(stateLabel)}</span></div><p>${esc(explanation)}</p>${dynamicValue ? `<p>${esc(tr('flow.heatingCurveDryRunValue'))}: <code>${esc(dynamicValue)}</code></p>` : ''}${trace ? `<details class="flow-simple-details" ${action.would_execute ? '' : 'open'}><summary>${esc(tr('flow.simpleConditions'))}</summary><div class="flow-simple-condition-list">${trace}</div></details>` : ''}</article>`;
}).join('');
}
@@ -1398,7 +1493,7 @@ function openFlowDryRun() {
function renderFlowDryRunResult(result) {
const host = $('#flowTestResults');
const actionName = id => flowNodeById(id) ? `${flowNodeTitle(FLOW_NODE_META[flowNodeById(id).kind])}: ${flowNodeSummary(flowNodeById(id))}` : id;
host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${uiIcon(item.matched ? 'check' : 'close')} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`;
host.innerHTML = `<div class="flow-test-summary"><strong>${esc(result.summary || tr('flow.dryRun'))}</strong><span>${esc(tr('flow.compiledPreview', result.compiled || { schedules: 0, automations: 0 }))}</span></div>${(result.actions || []).map(action => `<article class="flow-test-action ${action.would_execute ? 'pass' : 'blocked'}"><div><strong>${esc(actionName(action.node_id))}</strong><span class="badge ${action.would_execute ? 'active' : ''}">${esc(action.would_execute ? tr('flow.wouldExecute') : action.matched ? tr('flow.blockedByOwnership') : tr('flow.conditionFalse'))}</span></div>${action.blocked_reason ? `<p>${esc(tr('flow.blockReason'))}: <code>${esc(action.blocked_reason)}</code></p>` : ''}${Number.isFinite(Number(action.dynamic_value)) ? `<p>${esc(tr('flow.heatingCurveDryRunValue'))}: <code>${esc(`${Number(action.dynamic_value).toFixed(1)} °C → ${action.dynamic_value_target || 'target'}`)}</code></p>` : ''}<div class="flow-trace">${(action.trace || []).map(item => `<div><span>${uiIcon(item.matched ? 'check' : 'close')} ${esc(flowNodeTitle(FLOW_NODE_META[item.kind] || { title: item.kind }))}</span><code>${esc(JSON.stringify(item.actual))}</code></div>`).join('')}</div></article>`).join('')}`;
}
async function runFlowDryRun() {
if (!app.flowDraft) return;
@@ -1659,6 +1754,44 @@ function updateFlowConfig(input) {
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
}
function updateFlowHeatingCurvePoint(input) {
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
const index = Number(input.dataset.flowCurveIndex); const axis = input.dataset.flowCurvePoint;
const points = flowHeatingCurvePoints(node.config); const value = Number(input.value);
if (!Number.isInteger(index) || !points[index] || !['x', 'y'].includes(axis) || !Number.isFinite(value)) return;
points[index][axis] = value;
const duplicateX = axis === 'x' && points.some((point, pointIndex) => pointIndex !== index && Math.abs(point.x - value) < 0.000001);
if (duplicateX) { toast(tr('flow.heatingCurveDuplicateX'), true); renderFlowInspector(); return; }
node.config.points = points.sort((a, b) => a.x - b.x);
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
}
function addFlowHeatingCurvePoint() {
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
const points = flowHeatingCurvePoints(node.config); if (points.length >= 32) return;
if (points.length < 2) {
node.config.points = [{ x: -10, y: 28 }, { x: 10, y: 22 }];
} else {
let bestIndex = 0, bestGap = -Infinity;
for (let index = 0; index < points.length - 1; index += 1) {
const gap = points[index + 1].x - points[index].x;
if (gap > bestGap) { bestGap = gap; bestIndex = index; }
}
const a = points[bestIndex], b = points[bestIndex + 1];
const x = Number(((a.x + b.x) / 2).toFixed(1));
const y = Number(flowHeatingCurveInterpolate(points, x).toFixed(1));
points.push({ x, y }); node.config.points = points.sort((left, right) => left.x - right.x);
}
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
}
function removeFlowHeatingCurvePoint(index) {
const node = flowNodeById(app.flowSelectedNodeId); if (!node || node.kind !== 'heating_curve') return;
const points = flowHeatingCurvePoints(node.config); if (points.length <= 2 || !points[index]) return;
points.splice(index, 1); node.config.points = points;
app.flowDirty = true; renderFlowEditor(); flowHistoryCommit();
}
let flowDrag = null;
let flowMarquee = null;
@@ -1850,6 +1983,8 @@ document.addEventListener('keydown', event => {
});
document.addEventListener('click', event => {
const curveAdd = event.target.closest?.('[data-flow-curve-add]'); if (curveAdd) { addFlowHeatingCurvePoint(); return; }
const curveRemove = event.target.closest?.('[data-flow-curve-remove]'); if (curveRemove) { removeFlowHeatingCurvePoint(Number(curveRemove.dataset.flowCurveRemove)); return; }
const contextAction = event.target.closest?.('[data-flow-context-action]');
if (contextAction) {
const action = contextAction.dataset.flowContextAction;
@@ -1926,6 +2061,7 @@ document.addEventListener('click', event => {
else if (action === 'close-flow-editor') closeFlowEditor();
});
document.addEventListener('change', event => {
if (event.target.matches?.('[data-flow-curve-point]')) { updateFlowHeatingCurvePoint(event.target); return; }
if (event.target.matches?.('[data-flow-config]')) updateFlowConfig(event.target);
});
$('#flowDescriptionForm')?.addEventListener('submit', event => { event.preventDefault(); saveFlowDescription(); });