Intra Forum
    • Категории
    • Последние
    • Метки
    • Популярные
    • Зарегистрироваться
    • Войти

    Редактируемый из интерфейса пользователя график

    Запланировано Прикреплена Закрыта Перенесена HTML виджеты
    2 Сообщения 2 Posters 63 Просмотры 2 Watching
    Загружаем больше сообщений
    • Сначала старые
    • Сначала новые
    • По количеству голосов
    Ответить
    • Ответить, создав новую тему
    Авторизуйтесь, чтобы ответить
    Эта тема была удалена. Только пользователи с правом управления темами могут её видеть.
    • А Не в сети
      Александр
      отредактировано

      Добрый день.

      Дано: Газовый котел. Управление по протоколу OpenTherm.
      Уставка теплоносителя контура отопления погодозависимая.

      Сейчас изменение графика пользователем осуществляется с помощью изменения промежуточных точек - уставок. Все работает.

      Возможно ли в Интре реализовать визуальное изменение графика соотношения температуры наружного воздуха и температуры теплоносителя?
      Чтобы пользователь в графическом интерфейсе видел погодозависимый график и смог "тянуть" за точки на графике, меняя его кривую.

      1 ответ Последний ответ Ответить Цитировать 0
      • M Не в сети
        maksimvershinin Intra Support
        отредактировано maksimvershinin

        Добрый день. Есть пример такого графика. Он реализован на графике квадратичной функции, где с помощью мышки можно менять три точки, по сути три коэффициента 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));
        
          }
          
        };
        

        Снимок экрана 2026-05-04 в 09.00.13.png

        1 ответ Последний ответ Ответить Цитировать 0
        • V VAM переместил эту тему из IntraSCADA

        Здравствуйте! Похоже, вам интересна эта беседа, но у вас пока нет учетной записи.

        Вы устали просматривать одни и те же посты каждый раз, когда заходите на сайт? После регистрации, вам не придётся искать обсуждения в которых вы принимали участие, настройте уведомления о новых сообщениях так как вам это удобно (по электронной почте или уведомлением). У вас появится возможность сохранять закладки и ставить лайки постам, чтобы выразить свою благодарность другим участникам сообщества.

        С вашими комментариями этот пост может стать ещё лучше 💗

        Зарегистрироваться Войти
        • Первое сообщение
          Последнее сообщение