Добрый день. Есть пример такого графика. Он реализован на графике квадратичной функции, где с помощью мышки можно менять три точки, по сути три коэффициента a,b,c функции y = ax² − bx − c. Для удобства сделано три режима: День, Ночь, Эконом. Для каждого можно настроить свой график. Вот пример HTML виджета.
<style type="text/css">
#mode-select {
margin-bottom: 10px;
font-size: 14px;
padding: 5px;
}
#canvas {
border: 1px solid #000;
cursor: grab;
display: block;
margin: 0 auto;
}
#canvas:active {
cursor: grabbing;
}
#formula {
margin-top: 15px;
font-size: 16px;
font-weight: bold;
text-align: center;
padding: 5px;
}
#info {
margin-top: 10px;
color: #666;
font-size: 12px;
text-align: center;
padding: 0 10px;
}
#tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 5px 10px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
display: none;
z-index: 10;
}
</style>
<script type="text/javascript">
// Диагностика UUID (временно, для отладки — удалите после)
console.log('Available UUID sources:', {
template: '${uuid}',
window_uuid: window.uuid,
ihapi_uuid: window.ihapi?.uuid,
global_uuid: typeof uuid !== 'undefined' ? uuid : 'undefined'
});
// Используем глобальный uuid, если доступен
const currentUuid = uuid || window.uuid || window.ihapi?.uuid || '';
let canvas, ctx, formulaDiv, tooltip, modeSelect;
let points = [
{x: -20, y: 70},
{x: 0, y: 55},
{x: 10, y: 40}
];
let selectedPoint = null;
let isDragging = false;
let currentMode = 'day'; // Текущий режим по умолчанию
let modeCoeffs = { // Коэффициенты для каждого режима
day: {a: 0, b: 0, c: 0},
night: {a: 0, b: 0, c: 0},
eco: {a: 0, b: 0, c: 0}
};
let currentCoeffs = {a: 0, b: 0, c: 0}; // Текущие для рисования
// Параметры устройства и свойств
const deviceId = 'd0228';
const modes = ['day', 'night', 'eco'];
const fullProps = {
day: `${deviceId}_propABC_day`,
night: `${deviceId}_propABC_night`,
eco: `${deviceId}_propABC_eco`
};
// Параметры графика (фиксированные, как в начале)
const margin = 50;
const xMin = -30, xMax = 10;
const yMin = 45, yMax = 75;
// Функции преобразования координат (фиксированные)
function toCanvasX(x) {
return margin + (x - xMin) / (xMax - xMin) * (canvas.width - 2 * margin);
}
function toCanvasY(y) {
return (canvas.height - margin) - (y - yMin) / (yMax - yMin) * (canvas.height - 2 * margin);
}
function fromCanvasX(canvasX) {
return xMin + (canvasX - margin) / (canvas.width - 2 * margin) * (xMax - xMin);
}
function fromCanvasY(canvasY) {
return yMin + ((canvas.height - margin) - canvasY) / (canvas.height - 2 * margin) * (yMax - yMin);
}
// Вычисление коэффициентов квадратичной кривой
function computeQuadraticCoeffs() {
if (points.length !== 3) return {a: 0, b: 0, c: 0};
let [p1, p2, p3] = points;
let x1 = p1.x, y1 = p1.y;
let x2 = p2.x, y2 = p2.y;
let x3 = p3.x, y3 = p3.y;
let d12 = x2 - x1;
let d13 = x3 - x1;
let dd12 = x2 * x2 - x1 * x1;
let dd13 = x3 * x3 - x1 * x1;
let dy12 = y2 - y1;
let dy13 = y3 - y1;
let det = dd12 * d13 - dd13 * d12;
if (Math.abs(det) < 1e-10) {
let n = 3;
let sumX = x1 + x2 + x3;
let sumY = y1 + y2 + y3;
let sumXY = x1*y1 + x2*y2 + x3*y3;
let sumX2 = x1*x1 + x2*x2 + x3*x3;
let slope = (3 * sumXY - sumX * sumY) / (3 * sumX2 - sumX * sumX);
let intercept = (sumY - slope * sumX) / 3;
return {a: 0, b: slope, c: intercept};
}
let a = (dy12 * d13 - dy13 * d12) / det;
let b = (dd12 * dy13 - dd13 * dy12) / det;
let c = y1 - a * x1 * x1 - b * x1;
return {a, b, c};
}
// Вычисление y по x и коэффициентам
function computeY(x, coeffs) {
return coeffs.a * x * x + coeffs.b * x + coeffs.c;
}
// Обновление коэффициентов в устройстве (JSON в свойство для режима)
function updateCoeffsInDevice() {
const coeffs = computeQuadraticCoeffs();
currentCoeffs = coeffs; // Сохраняем текущие
const jsonStr = JSON.stringify(coeffs);
if (window.ihapi && window.ihapi.deviceCommand) {
window.ihapi.deviceCommand(deviceId, `propABC_${currentMode}`, jsonStr);
console.log(`Sent JSON for ${currentMode}:`, jsonStr); // Для отладки
}
}
// Обновление точек по коэффициентам (из подписки)
function updatePointsFromCoeffs(a, b, c) {
const coeffs = {a, b, c};
points.forEach(p => {
p.y = Math.max(yMin, Math.min(yMax, computeY(p.x, coeffs)));
});
}
// Смена режима
function switchMode(newMode) {
currentMode = newMode;
currentCoeffs = {...modeCoeffs[newMode]}; // Копируем
updatePointsFromCoeffs(currentCoeffs.a, currentCoeffs.b, currentCoeffs.c);
draw();
modeSelect.value = newMode; // Синхронизация селекта
}
// Рисование осей (фиксированные подписи, без наложения)
function drawAxes() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#000';
ctx.lineWidth = 1;
// X-ось
ctx.beginPath();
ctx.moveTo(margin, canvas.height - margin);
ctx.lineTo(canvas.width - margin, canvas.height - margin);
ctx.stroke();
// Y-ось
ctx.beginPath();
ctx.moveTo(margin, margin);
ctx.lineTo(margin, canvas.height - margin);
ctx.stroke();
// Подписи (подвинуты ниже/выше для избежания наложения)
ctx.fillText('Уличная T (°C)', canvas.width / 2, canvas.height - 10); // X-label ниже
ctx.save();
ctx.translate(10, canvas.height / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('Теплоноситель T (°C)', 0, 0); // Y-label слева
ctx.restore();
// Метки X (с отступом)
for (let i = 0; i <= 5; i++) {
let x = xMin + i * (xMax - xMin) / 5;
let cx = toCanvasX(x);
ctx.fillText(x.toFixed(0), cx - 10, canvas.height - margin + 20); // Отступ от оси
ctx.beginPath();
ctx.moveTo(cx, canvas.height - margin - 5);
ctx.lineTo(cx, canvas.height - margin + 5);
ctx.stroke();
}
// Метки Y (с отступом)
for (let i = 0; i <= 5; i++) {
let y = yMin + i * (yMax - yMin) / 5;
let cy = toCanvasY(y);
ctx.fillText(y.toFixed(0), 5, cy + 5); // Отступ слева
ctx.beginPath();
ctx.moveTo(margin - 5, cy);
ctx.lineTo(margin + 5, cy);
ctx.stroke();
}
}
// Рисование кривой и точек
function drawCurveAndPoints() {
const coeffs = computeQuadraticCoeffs();
// Кривая
ctx.strokeStyle = '#ff0000';
ctx.lineWidth = 2;
ctx.beginPath();
let step = (xMax - xMin) / 100;
for (let i = 0; i <= 100; i++) {
let x = xMin + i * step;
let y = Math.max(yMin, Math.min(yMax, computeY(x, coeffs)));
let cx = toCanvasX(x);
let cy = toCanvasY(y);
if (i === 0) ctx.moveTo(cx, cy); else ctx.lineTo(cx, cy);
}
ctx.stroke();
// Формула
formulaDiv.textContent = `y = ${coeffs.a.toFixed(3)}x² + ${coeffs.b.toFixed(3)}x + ${coeffs.c.toFixed(3)} (${currentMode})`;
// Точки
ctx.fillStyle = '#0000ff';
points.forEach((p, idx) => {
let cx = toCanvasX(p.x);
let cy = toCanvasY(p.y);
ctx.beginPath();
ctx.arc(cx, cy, 5, 0, 2 * Math.PI);
ctx.fill();
ctx.fillStyle = '#000';
ctx.fillText(idx + 1, cx + 8, cy - 8);
ctx.fillStyle = '#0000ff';
});
}
// Рисование перекрестия
function drawCrosshair(mouseX, mouseY) {
if (mouseX < margin || mouseX > canvas.width - margin || mouseY < margin || mouseY > canvas.height - margin) return;
ctx.strokeStyle = '#888';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
// Вертикальная
ctx.beginPath();
ctx.moveTo(mouseX, margin);
ctx.lineTo(mouseX, canvas.height - margin);
ctx.stroke();
// Горизонтальная
ctx.beginPath();
ctx.moveTo(margin, mouseY);
ctx.lineTo(canvas.width - margin, mouseY);
ctx.stroke();
ctx.setLineDash([]);
}
// Общая отрисовка
function draw(mouseX = null, mouseY = null) {
drawAxes();
drawCurveAndPoints();
if (mouseX !== null && mouseY !== null) {
drawCrosshair(mouseX, mouseY);
}
}
// Обработчики мыши
function initMouseHandlers() {
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
if (isDragging && selectedPoint !== null) {
const clampedX = Math.max(margin, Math.min(canvas.width - margin, mx));
const clampedY = Math.max(margin, Math.min(canvas.height - margin, my));
points[selectedPoint].x = fromCanvasX(clampedX);
points[selectedPoint].y = fromCanvasY(clampedY);
draw(); // Без crosshair во время dragging
} else {
const xReal = fromCanvasX(mx);
if (mx >= margin && mx <= canvas.width - margin && my >= margin && my <= canvas.height - margin) {
const yReal = Math.max(yMin, Math.min(yMax, computeY(xReal, currentCoeffs)));
tooltip.style.display = 'block';
tooltip.innerHTML = `Уличная: ${xReal.toFixed(1)}°C<br>Котел: ${yReal.toFixed(1)}°C`;
tooltip.style.left = `${e.pageX + 10}px`;
tooltip.style.top = `${e.pageY - 10}px`;
draw(mx, my);
} else {
tooltip.style.display = 'none';
draw();
}
}
});
canvas.addEventListener('mousedown', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
selectedPoint = null;
points.forEach((p, idx) => {
const cx = toCanvasX(p.x);
const cy = toCanvasY(p.y);
if (Math.hypot(mx - cx, my - cy) < 10) {
selectedPoint = idx;
isDragging = true;
tooltip.style.display = 'none';
}
});
});
canvas.addEventListener('mouseup', () => {
if (isDragging && selectedPoint !== null) {
isDragging = false;
selectedPoint = null;
updateCoeffsInDevice();
draw(); // Финальное обновление без crosshair
}
});
canvas.addEventListener('mouseleave', () => {
tooltip.style.display = 'none';
draw(); // Очистить crosshair
});
}
// Update от подписки на коэффициенты (парсим JSON)
function updateData(data) {
let updated = false;
modes.forEach(mode => {
const fullProp = fullProps[mode];
if (data[fullProp]) {
try {
const parsed = JSON.parse(data[fullProp]);
modeCoeffs[mode] = {a: parsed.a || 0, b: parsed.b || 0, c: parsed.c || 0};
if (mode === currentMode) {
currentCoeffs = {...modeCoeffs[mode]};
updatePointsFromCoeffs(currentCoeffs.a, currentCoeffs.b, currentCoeffs.c);
updated = true;
}
} catch (e) {
console.warn(`Invalid JSON for ${mode}:`, data[fullProp]);
}
}
});
if (updated) {
draw();
}
}
// Init
function init() {
canvas = document.getElementById('canvas');
if (!canvas) {
console.error('Canvas element not found');
return;
}
ctx = canvas.getContext('2d');
formulaDiv = document.getElementById('formula');
tooltip = document.getElementById('tooltip');
modeSelect = document.getElementById('mode-select');
if (!formulaDiv) {
console.error('Formula div not found');
return;
}
// Настройка селекта режима
modeSelect.addEventListener('change', (e) => {
switchMode(e.target.value);
});
// Безопасная настройка подписки и слушателей
if (window.ihapi && currentUuid) {
const localDestroy = function() {
if (window.ihapi && window.ihapi.deviceUnsub) {
window.ihapi.deviceUnsub(currentUuid, modes.map(m => fullProps[m]));
}
};
window.ihapi.addEventListener(currentUuid, 'destroy', localDestroy);
window.ihapi.addEventListener(currentUuid, 'data', updateData);
window.ihapi.deviceSub(currentUuid, modes.map(m => fullProps[m]));
console.log('ihapi setup successful with UUID:', currentUuid);
} else {
console.warn('ihapi setup skipped (UUID or ihapi not available)');
}
initMouseHandlers();
draw(); // Начальная отрисовка (обновится при 'data')
}
// Запуск init после загрузки DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
</script>
<select id="mode-select">
<option value="day">День</option>
<option value="night">Ночь</option>
<option value="eco">Эконом</option>
</select>
<canvas id="canvas" width="600" height="400"></canvas>
<div id="formula">Формула: y = 0x² + 0x + 0</div>
<div id="info">Перетаскивайте точки для настройки кривой. Выберите режим. Наведите мышь для перекрестия и тултипа с температурами. Изменения сохраняются в propABC_[режим].</div>
<div id="tooltip"></div>
Так же необходимо создать новый тип устройства, который будет состоять из трех свойств типа String propABC_day, propABC_night, propABC_eco. Создать экземпляр этого устройства в проекте и в HTML виджете указать id устройства в строке 72, переменная deviceId.
Далее необходимо создать сценарий который будет запускаться например раз в час и пересчитывать температуру теплоносителя в зависимости от температуры на улице, режима работы и коэффициентов кривых. Вот пример сценария:
/**
* @desc
* @version 5
*/
const mode = Device("DN001");
const curves = Device("DN002");
const outdoor = Device("AI_003");
const kotel_temp = Device("DT_003");
startOnChange([mode.state, curves])
const script = {
check() {
return mode.state !== 3;
},
start() {
let curvesArr = [];
curvesArr.push(JSON.parse(curves.propABC_day));
curvesArr.push(JSON.parse(curves.propABC_night));
curvesArr.push(JSON.parse(curves.propABC_eco));
const result = curvesArr[mode.state].a * outdoor.value * outdoor.value + curvesArr[mode.state].b * outdoor.value + curvesArr[mode.state].c
const clipped = Math.max(45, Math.min(75, result));
this.log(Math.round(clipped))
kotel_temp.setValue("setpoint", Math.round(clipped));
}
};
[image: 1777874455159-%D1%81%D0%BD%D0%B8%D0%BC%D0%BE%D0%BA-%D1%8D%D0%BA%D1%80%D0%B0%D0%BD%D0%B0-2026-05-04-%D0%B2-09.00.13-resized.png]