mirror of
http://10.0.2.1:3031/sauer/bfa-dryer-design.git
synced 2026-06-30 08:56:42 +10:00
Initial commit: BFA Banana Dryer HMI Design Collaboration Tool
Split-screen web app: HMI layout editor + machine simulation. - Drag/drop card layout with SortableJS - Add/remove/rename controls and pages - Comments system with user picker (Richard, Rob, Guido + add more) - SVG schematic with animated overlays (fan spin, conveyor scroll, burner glow) - Simulated PLC state in preview mode - Built-in help manual - Docker deployment behind Caddy at sauer.com.au/bfa-design Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
64254edcf7
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
static/photos/*
|
||||
!static/photos/.gitkeep
|
||||
layouts/current.json
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@ -0,0 +1,8 @@
|
||||
FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
RUN mkdir -p layouts static/photos
|
||||
EXPOSE 5001
|
||||
CMD ["python", "app.py"]
|
||||
86
app.py
Normal file
86
app.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""BFA Banana Dryer — HMI Design Collaboration Tool"""
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from flask import Flask, render_template, request, jsonify, send_from_directory
|
||||
|
||||
app = Flask(__name__)
|
||||
LAYOUT_DIR = os.path.join(os.path.dirname(__file__), "layouts")
|
||||
PHOTO_DIR = os.path.join(os.path.dirname(__file__), "static", "photos")
|
||||
os.makedirs(LAYOUT_DIR, exist_ok=True)
|
||||
os.makedirs(PHOTO_DIR, exist_ok=True)
|
||||
|
||||
CURRENT_LAYOUT = os.path.join(LAYOUT_DIR, "current.json")
|
||||
|
||||
def get_layout():
|
||||
if os.path.exists(CURRENT_LAYOUT):
|
||||
with open(CURRENT_LAYOUT) as f:
|
||||
return json.load(f)
|
||||
# First run — copy default
|
||||
default = os.path.join(LAYOUT_DIR, "default.json")
|
||||
with open(default) as f:
|
||||
layout = json.load(f)
|
||||
save_layout(layout)
|
||||
return layout
|
||||
|
||||
def save_layout(layout):
|
||||
with open(CURRENT_LAYOUT, "w") as f:
|
||||
json.dump(layout, f, indent=2)
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("editor.html")
|
||||
|
||||
@app.route("/api/layout", methods=["GET"])
|
||||
def api_get_layout():
|
||||
return jsonify(get_layout())
|
||||
|
||||
@app.route("/api/layout", methods=["POST"])
|
||||
def api_save_layout():
|
||||
layout = request.json
|
||||
save_layout(layout)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.route("/api/comment", methods=["POST"])
|
||||
def api_add_comment():
|
||||
data = request.json
|
||||
layout = get_layout()
|
||||
comment = {
|
||||
"id": str(uuid.uuid4())[:8],
|
||||
"target": data["target"],
|
||||
"user": data["user"],
|
||||
"time": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"text": data["text"]
|
||||
}
|
||||
layout.setdefault("comments", []).append(comment)
|
||||
save_layout(layout)
|
||||
return jsonify(comment)
|
||||
|
||||
@app.route("/api/users", methods=["POST"])
|
||||
def api_add_user():
|
||||
name = request.json.get("name", "").strip()
|
||||
if not name:
|
||||
return jsonify({"error": "empty name"}), 400
|
||||
layout = get_layout()
|
||||
if name not in layout.get("users", []):
|
||||
layout.setdefault("users", []).append(name)
|
||||
save_layout(layout)
|
||||
return jsonify({"ok": True, "users": layout["users"]})
|
||||
|
||||
@app.route("/api/photo", methods=["POST"])
|
||||
def api_upload_photo():
|
||||
if "file" not in request.files:
|
||||
return jsonify({"error": "no file"}), 400
|
||||
f = request.files["file"]
|
||||
fname = f"{int(time.time())}_{f.filename}"
|
||||
f.save(os.path.join(PHOTO_DIR, fname))
|
||||
return jsonify({"filename": fname, "url": f"/static/photos/{fname}"})
|
||||
|
||||
@app.route("/api/photos", methods=["GET"])
|
||||
def api_list_photos():
|
||||
photos = sorted(os.listdir(PHOTO_DIR)) if os.path.exists(PHOTO_DIR) else []
|
||||
return jsonify([{"filename": p, "url": f"/static/photos/{p}"} for p in photos if not p.startswith(".")])
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=5001, debug=True)
|
||||
12
docker-compose.yml
Normal file
12
docker-compose.yml
Normal file
@ -0,0 +1,12 @@
|
||||
services:
|
||||
bfa-design:
|
||||
build: .
|
||||
container_name: bfa-design
|
||||
ports:
|
||||
- "5001:5001"
|
||||
volumes:
|
||||
- ./layouts:/app/layouts
|
||||
- ./static/photos:/app/static/photos
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- FLASK_ENV=production
|
||||
43
layouts/default.json
Normal file
43
layouts/default.json
Normal file
@ -0,0 +1,43 @@
|
||||
{
|
||||
"version": 1,
|
||||
"project": "BFA Banana Dryer",
|
||||
"users": ["Richard", "Rob", "Guido"],
|
||||
"pages": [
|
||||
{
|
||||
"id": "p1",
|
||||
"name": "TEMPS / FAN",
|
||||
"cards": [
|
||||
{"id": "temp_0", "type": "temp", "label": "Heat Input", "color": "#ff4444", "width": "half", "sp_default": 130},
|
||||
{"id": "temp_1", "type": "temp", "label": "Product 1", "color": "#ff8844", "width": "half", "sp_default": 70},
|
||||
{"id": "temp_2", "type": "temp", "label": "Product 2", "color": "#ff8844", "width": "half", "sp_default": 60},
|
||||
{"id": "temp_3", "type": "temp", "label": "Exhaust", "color": "#44aaff", "width": "half", "sp_default": 50},
|
||||
{"id": "burner_0", "type": "burner", "label": "Burner", "width": "half"},
|
||||
{"id": "motor_0", "type": "motor", "label": "Hot Fan", "width": "half", "sp_default": 50}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"name": "MOTORS",
|
||||
"cards": [
|
||||
{"id": "motor_1", "type": "motor", "label": "Conveyor", "width": "half", "sp_default": 40},
|
||||
{"id": "motor_2", "type": "motor", "label": "Spinner", "width": "half", "sp_default": 70},
|
||||
{"id": "motor_3", "type": "motor", "label": "Agitator 1", "width": "half", "sp_default": 55},
|
||||
{"id": "motor_4", "type": "motor", "label": "Agitator 2", "width": "half", "sp_default": 55}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p3",
|
||||
"name": "OUTPUTS",
|
||||
"cards": [
|
||||
{"id": "output_0", "type": "output", "label": "Discharge Agitator", "width": "half"},
|
||||
{"id": "output_1", "type": "output", "label": "Brush", "width": "half"},
|
||||
{"id": "output_2", "type": "output", "label": "Loading Conveyor", "width": "half"},
|
||||
{"id": "output_3", "type": "output", "label": "Discharge Conveyor", "width": "half"},
|
||||
{"id": "output_4", "type": "output", "label": "Mill", "width": "half"},
|
||||
{"id": "output_5", "type": "output", "label": "Shaker Separator", "width": "half"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"comments": [],
|
||||
"machine_annotations": []
|
||||
}
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@ -0,0 +1 @@
|
||||
flask==3.1.*
|
||||
0
static/photos/.gitkeep
Normal file
0
static/photos/.gitkeep
Normal file
2
static/sortable.min.js
vendored
Normal file
2
static/sortable.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
687
templates/editor.html
Normal file
687
templates/editor.html
Normal file
@ -0,0 +1,687 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>BFA Banana Dryer — HMI Design Tool</title>
|
||||
<script src="/static/sortable.min.js"></script>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box;user-select:none;-webkit-tap-highlight-color:transparent}
|
||||
:root{--bg:#0a0e17;--card:#131a2b;--border:#1e2a45;--text:#e8ecf4;--text2:#7a8baa;--dim:#4a5670;--blue:#2d7ff9;--green:#00c853;--amber:#ffab00;--red:#ff1744;--thot:#ff4444;--twarm:#ff8844;--tcool:#44aaff;--track:#1a2240}
|
||||
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);height:100vh;overflow:hidden;display:flex;flex-direction:column}
|
||||
|
||||
/* Top toolbar */
|
||||
.toolbar{display:flex;align-items:center;justify-content:space-between;height:48px;padding:0 16px;background:#111827;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.toolbar .title{font-size:18px;font-weight:700}
|
||||
.toolbar .sub{font-size:13px;color:var(--text2);margin-left:16px}
|
||||
.toolbar-right{display:flex;align-items:center;gap:12px}
|
||||
.toolbar select,.toolbar button,.toolbar input{padding:6px 12px;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text);font-size:13px;cursor:pointer}
|
||||
.toolbar button:hover{background:var(--border)}
|
||||
.toolbar .mode-btn{padding:6px 16px;font-weight:600}
|
||||
.toolbar .mode-btn.active{background:var(--blue);border-color:var(--blue)}
|
||||
.toolbar .save-btn{background:var(--green);border-color:var(--green);color:#000;font-weight:600}
|
||||
.toolbar .help-btn{width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700}
|
||||
|
||||
/* Split screen */
|
||||
.split{display:flex;flex:1;overflow:hidden}
|
||||
.panel{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
.panel-left{border-right:2px solid var(--border)}
|
||||
.panel-header{height:36px;display:flex;align-items:center;padding:0 12px;background:#0d1220;border-bottom:1px solid var(--border);font-size:13px;font-weight:600;color:var(--text2);gap:8px;flex-shrink:0}
|
||||
.panel-header .tag{padding:2px 8px;border-radius:4px;font-size:11px}
|
||||
.panel-header .tag-edit{background:rgba(255,171,0,0.15);color:var(--amber)}
|
||||
.panel-header .tag-preview{background:rgba(0,200,83,0.15);color:var(--green)}
|
||||
|
||||
/* HMI Tabs */
|
||||
.hmi-tabs{display:flex;height:42px;background:#0d1220;border-bottom:1px solid var(--border);flex-shrink:0;overflow-x:auto}
|
||||
.hmi-tab{padding:0 20px;font-size:15px;font-weight:600;color:var(--text2);border:none;background:none;cursor:pointer;position:relative;white-space:nowrap}
|
||||
.hmi-tab.active{color:var(--blue)}
|
||||
.hmi-tab.active::after{content:'';position:absolute;bottom:0;left:8px;right:8px;height:3px;background:var(--blue);border-radius:2px 2px 0 0}
|
||||
.hmi-tab .comment-badge{position:absolute;top:4px;right:4px;width:16px;height:16px;border-radius:50%;background:var(--amber);color:#000;font-size:9px;display:flex;align-items:center;justify-content:center;font-weight:700}
|
||||
.hmi-tab-add{color:var(--dim);font-size:20px;padding:0 12px;cursor:pointer;border:none;background:none}
|
||||
|
||||
/* Card grid */
|
||||
.card-grid{flex:1;display:flex;flex-wrap:wrap;align-content:flex-start;padding:8px;gap:8px;overflow-y:auto}
|
||||
|
||||
/* Cards */
|
||||
.hmi-card{border-radius:10px;padding:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;cursor:pointer;transition:all 0.15s;position:relative;border:1px solid var(--border);min-height:80px}
|
||||
.hmi-card.half{width:calc(50% - 4px);height:calc(33.3% - 6px)}
|
||||
.hmi-card.full{width:100%;height:calc(33.3% - 6px)}
|
||||
|
||||
/* Card types */
|
||||
.hmi-card.type-temp{background:var(--card)}
|
||||
.hmi-card.type-motor,.hmi-card.type-output,.hmi-card.type-burner{background:var(--red)}
|
||||
.hmi-card.on.type-motor,.hmi-card.on.type-output{background:var(--green)}
|
||||
.hmi-card.on.type-burner{background:var(--amber)}
|
||||
|
||||
.hmi-card .card-label{font-size:18px;color:var(--text2);text-align:center}
|
||||
.hmi-card .card-value{font-size:28px;font-weight:700}
|
||||
.hmi-card .card-sub{font-size:12px;color:var(--dim)}
|
||||
.hmi-card .card-bar{width:80%;height:4px;background:var(--track);border-radius:2px;overflow:hidden}
|
||||
.hmi-card .card-bar-fill{height:100%;border-radius:2px;transition:width 0.5s}
|
||||
|
||||
/* Edit mode overlays */
|
||||
.edit-overlay{display:none}
|
||||
.edit-mode .edit-overlay{display:flex}
|
||||
.hmi-card .edit-overlay{position:absolute;top:4px;right:4px;gap:4px}
|
||||
.hmi-card .edit-btn{width:24px;height:24px;border-radius:4px;border:1px solid var(--border);background:var(--bg);color:var(--text2);font-size:12px;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||
.hmi-card .edit-btn:hover{background:var(--red);color:var(--text)}
|
||||
.hmi-card .comment-btn:hover{background:var(--amber);color:#000}
|
||||
.hmi-card .drag-handle{position:absolute;top:4px;left:4px;cursor:grab;color:var(--dim);font-size:14px}
|
||||
.edit-mode .hmi-card{border:2px dashed var(--dim)}
|
||||
.edit-mode .hmi-card:hover{border-color:var(--blue)}
|
||||
.sortable-ghost{opacity:0.4;border:2px dashed var(--blue) !important}
|
||||
|
||||
/* Add card palette */
|
||||
.add-palette{display:none;padding:8px;gap:8px;border-top:1px solid var(--border);flex-shrink:0}
|
||||
.edit-mode .add-palette{display:flex}
|
||||
.add-palette button{flex:1;padding:10px;border:2px dashed var(--dim);border-radius:8px;background:none;color:var(--text2);font-size:13px;cursor:pointer}
|
||||
.add-palette button:hover{border-color:var(--blue);color:var(--blue)}
|
||||
|
||||
/* Comment badge on cards */
|
||||
.hmi-card .card-comment-badge{position:absolute;bottom:4px;right:4px;width:20px;height:20px;border-radius:50%;background:var(--amber);color:#000;font-size:10px;font-weight:700;display:none;align-items:center;justify-content:center}
|
||||
.hmi-card .card-comment-badge.has-comments{display:flex}
|
||||
|
||||
/* Comment panel */
|
||||
.comment-panel{position:fixed;right:0;top:48px;width:360px;height:calc(100vh - 48px);background:var(--card);border-left:2px solid var(--border);z-index:100;display:none;flex-direction:column}
|
||||
.comment-panel.open{display:flex}
|
||||
.comment-panel-header{padding:12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
|
||||
.comment-panel-header h3{font-size:16px}
|
||||
.comment-list{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
|
||||
.comment-item{background:var(--bg);border-radius:8px;padding:10px}
|
||||
.comment-item .comment-user{font-size:12px;font-weight:600;color:var(--blue)}
|
||||
.comment-item .comment-time{font-size:10px;color:var(--dim);margin-left:8px}
|
||||
.comment-item .comment-text{font-size:14px;margin-top:4px}
|
||||
.comment-input{display:flex;gap:8px;padding:12px;border-top:1px solid var(--border)}
|
||||
.comment-input input{flex:1;padding:8px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:14px}
|
||||
.comment-input button{padding:8px 16px;border:none;border-radius:6px;background:var(--blue);color:var(--text);font-weight:600;cursor:pointer}
|
||||
|
||||
/* Machine simulation panel */
|
||||
.sim-container{flex:1;position:relative;display:flex;align-items:center;justify-content:center;overflow:hidden;background:#080c14}
|
||||
|
||||
/* SVG Schematic */
|
||||
.schematic{width:90%;max-height:90%}
|
||||
.schematic .equip{fill:var(--card);stroke:var(--border);stroke-width:2}
|
||||
.schematic .equip-label{fill:var(--text2);font-size:12px;text-anchor:middle;font-family:'Segoe UI',sans-serif}
|
||||
.schematic .flow-arrow{fill:none;stroke:var(--dim);stroke-width:2;marker-end:url(#arrowhead)}
|
||||
.schematic .anim-overlay{opacity:0;transition:opacity 0.3s}
|
||||
.schematic .anim-overlay.active{opacity:1}
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin{100%{transform-origin:center;transform:rotate(360deg)}}
|
||||
@keyframes pulse-glow{0%,100%{opacity:0.3}50%{opacity:1}}
|
||||
@keyframes scroll-belt{100%{stroke-dashoffset:-20}}
|
||||
@keyframes fall-particles{0%{opacity:1;transform:translateY(0)}100%{opacity:0;transform:translateY(30px)}}
|
||||
.fan-anim{animation:spin 1s linear infinite}
|
||||
.burner-glow{animation:pulse-glow 1.5s ease-in-out infinite}
|
||||
.conveyor-anim{animation:scroll-belt 0.5s linear infinite}
|
||||
.discharge-anim{animation:fall-particles 1s ease-out infinite}
|
||||
.agitator-anim{animation:spin 2s linear infinite}
|
||||
|
||||
/* Help panel */
|
||||
.help-panel{position:fixed;right:0;top:48px;width:400px;height:calc(100vh - 48px);background:var(--card);border-left:2px solid var(--border);z-index:150;display:none;flex-direction:column;overflow-y:auto;padding:24px}
|
||||
.help-panel.open{display:flex}
|
||||
.help-panel h2{font-size:20px;margin-bottom:16px;color:var(--text)}
|
||||
.help-panel h3{font-size:16px;margin-top:16px;margin-bottom:8px;color:var(--blue)}
|
||||
.help-panel p{font-size:14px;color:var(--text2);line-height:1.6;margin-bottom:8px}
|
||||
.help-panel .help-close{position:absolute;top:12px;right:12px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||
|
||||
/* Inline edit */
|
||||
.inline-edit{background:transparent;border:none;border-bottom:2px solid var(--blue);color:var(--text);font-size:inherit;font-family:inherit;text-align:center;outline:none;width:100%}
|
||||
|
||||
/* Popup */
|
||||
#popup-overlay{position:fixed;inset:0;background:rgba(0,0,0,0.5);display:none;align-items:center;justify-content:center;z-index:50}
|
||||
#popup-overlay.active{display:flex}
|
||||
.popup{background:var(--card);border:3px solid var(--blue);border-radius:16px;padding:24px;width:500px;display:flex;flex-direction:column;align-items:center;gap:16px}
|
||||
.popup .title{font-size:24px;color:var(--text2)}
|
||||
.popup .live{font-size:32px;font-weight:700}
|
||||
.popup .sp-row{display:flex;align-items:center;gap:20px}
|
||||
.popup .sp-btn{width:70px;height:70px;border:2px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text);font-size:28px;cursor:pointer;display:flex;align-items:center;justify-content:center}
|
||||
.popup .sp-btn:active{background:var(--border)}
|
||||
.popup .sp-val{font-size:32px;min-width:80px;text-align:center}
|
||||
.popup .close-btn{width:160px;height:44px;border:none;border-radius:8px;background:var(--blue);color:var(--text);font-size:16px;font-weight:600;cursor:pointer}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- TOOLBAR -->
|
||||
<div class="toolbar">
|
||||
<div style="display:flex;align-items:center">
|
||||
<span class="title">BFA Banana Dryer</span>
|
||||
<span class="sub">HMI Design Tool</span>
|
||||
</div>
|
||||
<div class="toolbar-right">
|
||||
<select id="user-select"></select>
|
||||
<button onclick="addUser()">+ Add Person</button>
|
||||
<span style="color:var(--dim)">|</span>
|
||||
<button class="mode-btn active" id="btn-edit" onclick="setMode('edit')">EDIT</button>
|
||||
<button class="mode-btn" id="btn-preview" onclick="setMode('preview')">PREVIEW</button>
|
||||
<span style="color:var(--dim)">|</span>
|
||||
<button class="save-btn" onclick="saveLayout()">Save</button>
|
||||
<button onclick="exportLayout()">Export JSON</button>
|
||||
<button class="help-btn" onclick="toggleHelp()">?</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SPLIT SCREEN -->
|
||||
<div class="split">
|
||||
<!-- LEFT: HMI -->
|
||||
<div class="panel panel-left" id="hmi-panel">
|
||||
<div class="panel-header">
|
||||
HMI LAYOUT
|
||||
<span class="tag tag-edit edit-overlay">EDIT MODE</span>
|
||||
<span class="tag tag-preview" id="preview-tag" style="display:none">PREVIEW MODE</span>
|
||||
</div>
|
||||
<div class="hmi-tabs" id="hmi-tabs"></div>
|
||||
<div class="card-grid edit-mode" id="card-grid"></div>
|
||||
<div class="add-palette" id="add-palette">
|
||||
<button onclick="addCard('temp')">+ Temperature</button>
|
||||
<button onclick="addCard('motor')">+ Motor</button>
|
||||
<button onclick="addCard('output')">+ Output</button>
|
||||
<button onclick="addCard('burner')">+ Burner</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: MACHINE SIM -->
|
||||
<div class="panel">
|
||||
<div class="panel-header">MACHINE SIMULATION</div>
|
||||
<div class="sim-container" id="sim-container">
|
||||
<!-- SVG schematic inserted by JS -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- COMMENT PANEL -->
|
||||
<div class="comment-panel" id="comment-panel">
|
||||
<div class="comment-panel-header">
|
||||
<h3 id="comment-target-name">Comments</h3>
|
||||
<button onclick="closeComments()" style="background:none;border:none;color:var(--text);font-size:18px;cursor:pointer">✕</button>
|
||||
</div>
|
||||
<div class="comment-list" id="comment-list"></div>
|
||||
<div class="comment-input">
|
||||
<input id="comment-text" placeholder="Add a comment..." onkeydown="if(event.key==='Enter')submitComment()">
|
||||
<button onclick="submitComment()">Post</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HELP PANEL -->
|
||||
<div class="help-panel" id="help-panel">
|
||||
<button class="help-close" onclick="toggleHelp()">✕</button>
|
||||
<h2>How to Use This Tool</h2>
|
||||
<h3>Switching Modes</h3>
|
||||
<p>Use the <b>EDIT</b> and <b>PREVIEW</b> buttons in the toolbar. In Edit mode you can rearrange and modify controls. In Preview mode the HMI works like the real device.</p>
|
||||
<h3>Rearranging Controls</h3>
|
||||
<p>In Edit mode, <b>drag any card</b> by its handle (top-left corner) to move it within a page or between pages (drag to the tab name).</p>
|
||||
<h3>Adding Controls</h3>
|
||||
<p>In Edit mode, use the <b>+ Temperature</b>, <b>+ Motor</b>, <b>+ Output</b>, or <b>+ Burner</b> buttons at the bottom to add a new control to the current page.</p>
|
||||
<h3>Renaming</h3>
|
||||
<p>In Edit mode, <b>click on any card's name</b> to edit it. Press Enter to confirm.</p>
|
||||
<h3>Deleting</h3>
|
||||
<p>In Edit mode, click the <b>✕</b> button on the top-right of a card to remove it.</p>
|
||||
<h3>Pages / Tabs</h3>
|
||||
<p>Click the <b>+</b> button after the last tab to add a new page. Right-click a tab name to rename or delete it.</p>
|
||||
<h3>Comments</h3>
|
||||
<p>Click the <b>💬</b> button on any card to open the comment panel. Type your comment and click Post. Everyone can see all comments.</p>
|
||||
<h3>Saving</h3>
|
||||
<p>Click <b>Save</b> to save the current layout to the server. Click <b>Export JSON</b> to download the layout file.</p>
|
||||
<h3>Machine Simulation</h3>
|
||||
<p>The right panel shows a schematic of the dryer. When you toggle controls in Preview mode, the equipment animations respond (fan spins, conveyor moves, burner glows, etc.).</p>
|
||||
<h3>Choosing Your Name</h3>
|
||||
<p>Use the dropdown in the toolbar to select your name. Click <b>+ Add Person</b> if you're not in the list.</p>
|
||||
</div>
|
||||
|
||||
<!-- POPUP -->
|
||||
<div id="popup-overlay" onclick="if(event.target===this)closePopup()">
|
||||
<div class="popup" id="popup"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ══════════════════════════════════════════════════════
|
||||
// STATE
|
||||
// ══════════════════════════════════════════════════════
|
||||
let layout = null;
|
||||
let currentPage = 0;
|
||||
let mode = 'edit'; // 'edit' or 'preview'
|
||||
let currentUser = localStorage.getItem('bfa-user') || '';
|
||||
let simState = {}; // id -> {on:bool, value:number}
|
||||
let commentTarget = null;
|
||||
let sortableInstance = null;
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// INIT
|
||||
// ══════════════════════════════════════════════════════
|
||||
async function init() {
|
||||
const resp = await fetch('/api/layout');
|
||||
layout = await resp.json();
|
||||
if (!currentUser && layout.users.length > 0) currentUser = layout.users[0];
|
||||
initUsers();
|
||||
initSimState();
|
||||
renderTabs();
|
||||
renderCards();
|
||||
renderSchematic();
|
||||
}
|
||||
|
||||
function initUsers() {
|
||||
const sel = document.getElementById('user-select');
|
||||
sel.innerHTML = '';
|
||||
(layout.users || []).forEach(u => {
|
||||
const o = document.createElement('option');
|
||||
o.value = u; o.textContent = u;
|
||||
if (u === currentUser) o.selected = true;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
sel.onchange = () => { currentUser = sel.value; localStorage.setItem('bfa-user', currentUser); };
|
||||
}
|
||||
|
||||
function initSimState() {
|
||||
layout.pages.forEach(p => p.cards.forEach(c => {
|
||||
if (c.type === 'temp') simState[c.id] = {on: true, value: c.sp_default || 70};
|
||||
else if (c.type === 'motor') simState[c.id] = {on: false, value: c.sp_default || 50};
|
||||
else if (c.type === 'burner') simState[c.id] = {on: false, value: 0};
|
||||
else simState[c.id] = {on: false, value: 0};
|
||||
}));
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
const name = prompt('Enter name:');
|
||||
if (!name) return;
|
||||
await fetch('/api/users', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({name})});
|
||||
layout.users.push(name);
|
||||
currentUser = name;
|
||||
localStorage.setItem('bfa-user', name);
|
||||
initUsers();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// MODE
|
||||
// ══════════════════════════════════════════════════════
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
document.getElementById('btn-edit').classList.toggle('active', m === 'edit');
|
||||
document.getElementById('btn-preview').classList.toggle('active', m === 'preview');
|
||||
document.getElementById('card-grid').classList.toggle('edit-mode', m === 'edit');
|
||||
document.getElementById('preview-tag').style.display = m === 'preview' ? 'inline' : 'none';
|
||||
document.querySelectorAll('.edit-overlay').forEach(e => e.style.display = m === 'edit' ? 'flex' : 'none');
|
||||
document.getElementById('add-palette').style.display = m === 'edit' ? 'flex' : 'none';
|
||||
if (m === 'edit') initSortable();
|
||||
else if (sortableInstance) { sortableInstance.destroy(); sortableInstance = null; }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// TABS
|
||||
// ══════════════════════════════════════════════════════
|
||||
function renderTabs() {
|
||||
const container = document.getElementById('hmi-tabs');
|
||||
container.innerHTML = '';
|
||||
layout.pages.forEach((p, i) => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'hmi-tab' + (i === currentPage ? ' active' : '');
|
||||
btn.textContent = p.name;
|
||||
btn.onclick = () => { currentPage = i; renderTabs(); renderCards(); };
|
||||
btn.oncontextmenu = (e) => { e.preventDefault(); tabContextMenu(i); };
|
||||
// Comment badge for page
|
||||
const pageComments = (layout.comments || []).filter(c => c.target === 'page:' + p.id);
|
||||
if (pageComments.length > 0) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'comment-badge';
|
||||
badge.textContent = pageComments.length;
|
||||
btn.appendChild(badge);
|
||||
}
|
||||
container.appendChild(btn);
|
||||
});
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.className = 'hmi-tab-add';
|
||||
addBtn.textContent = '+';
|
||||
addBtn.onclick = addPage;
|
||||
container.appendChild(addBtn);
|
||||
}
|
||||
|
||||
function addPage() {
|
||||
const name = prompt('New page name:', 'NEW PAGE');
|
||||
if (!name) return;
|
||||
layout.pages.push({id: 'p' + Date.now(), name: name.toUpperCase(), cards: []});
|
||||
currentPage = layout.pages.length - 1;
|
||||
renderTabs(); renderCards();
|
||||
}
|
||||
|
||||
function tabContextMenu(i) {
|
||||
const action = prompt(`Page "${layout.pages[i].name}"\n\nType "rename" to rename, "delete" to delete, or "comment" to add a comment:`);
|
||||
if (!action) return;
|
||||
if (action.toLowerCase() === 'rename') {
|
||||
const name = prompt('New name:', layout.pages[i].name);
|
||||
if (name) { layout.pages[i].name = name.toUpperCase(); renderTabs(); }
|
||||
} else if (action.toLowerCase() === 'delete') {
|
||||
if (layout.pages.length <= 1) { alert('Must keep at least one page.'); return; }
|
||||
if (confirm(`Delete page "${layout.pages[i].name}"?`)) {
|
||||
layout.pages.splice(i, 1);
|
||||
if (currentPage >= layout.pages.length) currentPage = layout.pages.length - 1;
|
||||
renderTabs(); renderCards();
|
||||
}
|
||||
} else if (action.toLowerCase() === 'comment') {
|
||||
openComments('page:' + layout.pages[i].id, layout.pages[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// CARDS
|
||||
// ══════════════════════════════════════════════════════
|
||||
function renderCards() {
|
||||
const grid = document.getElementById('card-grid');
|
||||
grid.innerHTML = '';
|
||||
const page = layout.pages[currentPage];
|
||||
if (!page) return;
|
||||
page.cards.forEach(c => {
|
||||
const el = createCardEl(c);
|
||||
grid.appendChild(el);
|
||||
});
|
||||
if (mode === 'edit') initSortable();
|
||||
}
|
||||
|
||||
function createCardEl(c) {
|
||||
const el = document.createElement('div');
|
||||
el.className = `hmi-card ${c.width || 'half'} type-${c.type}`;
|
||||
el.dataset.id = c.id;
|
||||
const s = simState[c.id] || {on: false, value: 0};
|
||||
if (c.type !== 'temp' && s.on) el.classList.add('on');
|
||||
|
||||
// Drag handle (edit mode)
|
||||
el.innerHTML = `<span class="drag-handle edit-overlay">☰</span>`;
|
||||
|
||||
// Edit buttons
|
||||
el.innerHTML += `<div class="edit-overlay" style="position:absolute;top:4px;right:4px;gap:4px">
|
||||
<button class="edit-btn comment-btn" onclick="event.stopPropagation();openComments('${c.id}','${c.label}')">💬</button>
|
||||
<button class="edit-btn" onclick="event.stopPropagation();deleteCard('${c.id}')">✕</button>
|
||||
</div>`;
|
||||
|
||||
// Comment badge
|
||||
const commentCount = (layout.comments || []).filter(x => x.target === c.id).length;
|
||||
el.innerHTML += `<div class="card-comment-badge ${commentCount > 0 ? 'has-comments' : ''}">${commentCount}</div>`;
|
||||
|
||||
if (c.type === 'temp') {
|
||||
const color = c.color || '#ff8844';
|
||||
el.innerHTML += `<div class="card-label" onclick="if(mode==='edit'){event.stopPropagation();renameCard('${c.id}')}">${c.label}</div>`;
|
||||
el.innerHTML += `<div class="card-value" style="color:${color}">${Math.round(s.value)} °C</div>`;
|
||||
el.innerHTML += `<div class="card-bar"><div class="card-bar-fill" style="width:${Math.min(100,s.value/2)}%;background:${color}"></div></div>`;
|
||||
el.innerHTML += `<div class="card-sub">SP: ${c.sp_default || 70} °C</div>`;
|
||||
el.onclick = () => { if (mode === 'preview') openTempPopup(c); };
|
||||
} else if (c.type === 'motor') {
|
||||
el.innerHTML += `<div style="display:flex;gap:10px;align-items:center">
|
||||
<span class="card-label" onclick="if(mode==='edit'){event.stopPropagation();renameCard('${c.id}')}">${c.label}</span>
|
||||
<span style="font-size:18px;color:var(--text2)">${c.sp_default || 50}%</span>
|
||||
</div>`;
|
||||
el.innerHTML += `<div class="card-value">${s.on ? 'ON' : 'OFF'}</div>`;
|
||||
el.onclick = () => { if (mode === 'preview') toggleSim(c.id); };
|
||||
} else if (c.type === 'burner') {
|
||||
el.innerHTML += `<div class="card-label" onclick="if(mode==='edit'){event.stopPropagation();renameCard('${c.id}')}">${c.label}</div>`;
|
||||
el.innerHTML += `<div class="card-value">${s.on ? 'ON' : 'OFF'}</div>`;
|
||||
el.onclick = () => { if (mode === 'preview') toggleSim(c.id); };
|
||||
} else { // output
|
||||
el.innerHTML += `<div class="card-label" onclick="if(mode==='edit'){event.stopPropagation();renameCard('${c.id}')}" style="text-align:center">${c.label}</div>`;
|
||||
el.innerHTML += `<div class="card-value">${s.on ? 'ON' : 'OFF'}</div>`;
|
||||
el.onclick = () => { if (mode === 'preview') toggleSim(c.id); };
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// DRAG & DROP
|
||||
// ══════════════════════════════════════════════════════
|
||||
function initSortable() {
|
||||
if (sortableInstance) sortableInstance.destroy();
|
||||
const grid = document.getElementById('card-grid');
|
||||
sortableInstance = new Sortable(grid, {
|
||||
animation: 150,
|
||||
handle: '.drag-handle',
|
||||
ghostClass: 'sortable-ghost',
|
||||
onEnd: (evt) => {
|
||||
const page = layout.pages[currentPage];
|
||||
const [moved] = page.cards.splice(evt.oldIndex, 1);
|
||||
page.cards.splice(evt.newIndex, 0, moved);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// CARD ACTIONS
|
||||
// ══════════════════════════════════════════════════════
|
||||
function addCard(type) {
|
||||
const id = type + '_' + Date.now();
|
||||
const defaults = {
|
||||
temp: {label: 'New Temp', color: '#ff8844', sp_default: 70},
|
||||
motor: {label: 'New Motor', sp_default: 50},
|
||||
output: {label: 'New Output'},
|
||||
burner: {label: 'Burner'}
|
||||
};
|
||||
const card = {id, type, width: 'half', ...defaults[type]};
|
||||
layout.pages[currentPage].cards.push(card);
|
||||
simState[id] = {on: false, value: card.sp_default || 0};
|
||||
renderCards();
|
||||
}
|
||||
|
||||
function deleteCard(id) {
|
||||
if (!confirm('Delete this control?')) return;
|
||||
const page = layout.pages[currentPage];
|
||||
page.cards = page.cards.filter(c => c.id !== id);
|
||||
layout.comments = (layout.comments || []).filter(c => c.target !== id);
|
||||
renderCards();
|
||||
}
|
||||
|
||||
function renameCard(id) {
|
||||
const page = layout.pages[currentPage];
|
||||
const card = page.cards.find(c => c.id === id);
|
||||
if (!card) return;
|
||||
const name = prompt('New name:', card.label);
|
||||
if (name) { card.label = name; renderCards(); }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// SIMULATION (PREVIEW MODE)
|
||||
// ══════════════════════════════════════════════════════
|
||||
function toggleSim(id) {
|
||||
if (!simState[id]) simState[id] = {on: false, value: 0};
|
||||
simState[id].on = !simState[id].on;
|
||||
renderCards();
|
||||
updateSchematicAnimations();
|
||||
}
|
||||
|
||||
function openTempPopup(c) {
|
||||
const p = document.getElementById('popup');
|
||||
const s = simState[c.id] || {value: 70};
|
||||
p.style.borderColor = c.color || 'var(--blue)';
|
||||
p.innerHTML = `
|
||||
<div class="title">${c.label}</div>
|
||||
<div class="live" style="color:${c.color || 'var(--twarm)'}">${Math.round(s.value)} °C</div>
|
||||
<div style="font-size:14px;color:var(--dim)">SETPOINT</div>
|
||||
<div class="sp-row">
|
||||
<button class="sp-btn" onclick="adjTempSP('${c.id}',-1)">-</button>
|
||||
<div class="sp-val" id="sp-pv">${c.sp_default || 70}</div>
|
||||
<button class="sp-btn" onclick="adjTempSP('${c.id}',1)">+</button>
|
||||
</div>
|
||||
<button class="close-btn" onclick="closePopup()">CLOSE</button>`;
|
||||
document.getElementById('popup-overlay').classList.add('active');
|
||||
}
|
||||
|
||||
function adjTempSP(id, d) {
|
||||
const card = findCard(id);
|
||||
if (!card) return;
|
||||
card.sp_default = Math.max(0, Math.min(200, (card.sp_default || 70) + d));
|
||||
document.getElementById('sp-pv').textContent = card.sp_default;
|
||||
if (simState[id]) simState[id].value = card.sp_default;
|
||||
renderCards();
|
||||
}
|
||||
|
||||
function closePopup() { document.getElementById('popup-overlay').classList.remove('active'); }
|
||||
|
||||
function findCard(id) {
|
||||
for (const p of layout.pages) { const c = p.cards.find(x => x.id === id); if (c) return c; }
|
||||
return null;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// COMMENTS
|
||||
// ══════════════════════════════════════════════════════
|
||||
function openComments(target, name) {
|
||||
commentTarget = target;
|
||||
document.getElementById('comment-target-name').textContent = 'Comments: ' + name;
|
||||
const list = document.getElementById('comment-list');
|
||||
const comments = (layout.comments || []).filter(c => c.target === target);
|
||||
list.innerHTML = comments.map(c => `
|
||||
<div class="comment-item">
|
||||
<span class="comment-user">${c.user}</span>
|
||||
<span class="comment-time">${c.time}</span>
|
||||
<div class="comment-text">${c.text}</div>
|
||||
</div>
|
||||
`).join('') || '<p style="color:var(--dim);font-size:13px">No comments yet.</p>';
|
||||
document.getElementById('comment-panel').classList.add('open');
|
||||
document.getElementById('comment-text').focus();
|
||||
}
|
||||
|
||||
function closeComments() { document.getElementById('comment-panel').classList.remove('open'); commentTarget = null; }
|
||||
|
||||
async function submitComment() {
|
||||
const text = document.getElementById('comment-text').value.trim();
|
||||
if (!text || !commentTarget) return;
|
||||
const resp = await fetch('/api/comment', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({target: commentTarget, user: currentUser, text})
|
||||
});
|
||||
const comment = await resp.json();
|
||||
layout.comments.push(comment);
|
||||
document.getElementById('comment-text').value = '';
|
||||
openComments(commentTarget, document.getElementById('comment-target-name').textContent.replace('Comments: ', ''));
|
||||
renderCards(); renderTabs();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// SAVE / EXPORT
|
||||
// ══════════════════════════════════════════════════════
|
||||
async function saveLayout() {
|
||||
await fetch('/api/layout', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(layout)});
|
||||
alert('Layout saved!');
|
||||
}
|
||||
|
||||
function exportLayout() {
|
||||
const blob = new Blob([JSON.stringify(layout, null, 2)], {type: 'application/json'});
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = 'bfa-dryer-layout.json'; a.click();
|
||||
}
|
||||
|
||||
function toggleHelp() { document.getElementById('help-panel').classList.toggle('open'); }
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// MACHINE SCHEMATIC (SVG)
|
||||
// ══════════════════════════════════════════════════════
|
||||
function renderSchematic() {
|
||||
document.getElementById('sim-container').innerHTML = `
|
||||
<svg class="schematic" viewBox="0 0 800 400" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
||||
<polygon points="0 0, 8 3, 0 6" fill="var(--dim)"/>
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
<!-- Flow arrows -->
|
||||
<line class="flow-arrow" x1="130" y1="200" x2="195" y2="200"/>
|
||||
<line class="flow-arrow" x1="340" y1="200" x2="405" y2="200"/>
|
||||
<line class="flow-arrow" x1="540" y1="200" x2="575" y2="200"/>
|
||||
<line class="flow-arrow" x1="540" y1="280" x2="575" y2="280"/>
|
||||
|
||||
<!-- Loading Hopper -->
|
||||
<rect class="equip" x="40" y="160" width="90" height="80" rx="6"/>
|
||||
<text class="equip-label" x="85" y="205">Loading</text>
|
||||
|
||||
<!-- Dryer Drum -->
|
||||
<rect class="equip" x="200" y="130" width="140" height="140" rx="12"/>
|
||||
<text class="equip-label" x="270" y="195">Dryer</text>
|
||||
<text class="equip-label" x="270" y="215" font-size="10" fill="var(--dim)">Drum</text>
|
||||
|
||||
<!-- Burner (on drum) -->
|
||||
<circle class="equip" cx="200" cy="200" r="20"/>
|
||||
<text class="equip-label" x="200" y="204" font-size="9">🔥</text>
|
||||
<circle class="anim-overlay burner-glow" id="sim-burner" cx="200" cy="200" r="24" fill="none" stroke="var(--amber)" stroke-width="4"/>
|
||||
|
||||
<!-- Fan (on drum) -->
|
||||
<circle class="equip" cx="340" cy="150" r="22"/>
|
||||
<text class="equip-label" x="340" y="154" font-size="9">Fan</text>
|
||||
<g class="anim-overlay fan-anim" id="sim-fan" transform-origin="340 150">
|
||||
<line x1="340" y1="130" x2="340" y2="170" stroke="var(--blue)" stroke-width="3"/>
|
||||
<line x1="320" y1="150" x2="360" y2="150" stroke="var(--blue)" stroke-width="3"/>
|
||||
<line x1="326" y1="136" x2="354" y2="164" stroke="var(--blue)" stroke-width="3"/>
|
||||
<line x1="354" y1="136" x2="326" y2="164" stroke="var(--blue)" stroke-width="3"/>
|
||||
</g>
|
||||
|
||||
<!-- Conveyor -->
|
||||
<rect class="equip" x="410" y="180" width="130" height="40" rx="4"/>
|
||||
<text class="equip-label" x="475" y="205">Conveyor</text>
|
||||
<line class="anim-overlay conveyor-anim" id="sim-conveyor" x1="420" y1="210" x2="530" y2="210" stroke="var(--green)" stroke-width="3" stroke-dasharray="10 10"/>
|
||||
|
||||
<!-- Agitators (on conveyor) -->
|
||||
<circle class="equip" cx="440" cy="170" r="12"/>
|
||||
<text class="equip-label" x="440" y="174" font-size="8">A1</text>
|
||||
<g class="anim-overlay agitator-anim" id="sim-agit1" transform-origin="440 170">
|
||||
<line x1="440" y1="160" x2="440" y2="180" stroke="var(--blue)" stroke-width="2"/>
|
||||
<line x1="430" y1="170" x2="450" y2="170" stroke="var(--blue)" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<circle class="equip" cx="510" cy="170" r="12"/>
|
||||
<text class="equip-label" x="510" y="174" font-size="8">A2</text>
|
||||
<g class="anim-overlay agitator-anim" id="sim-agit2" transform-origin="510 170">
|
||||
<line x1="510" y1="160" x2="510" y2="180" stroke="var(--blue)" stroke-width="2"/>
|
||||
<line x1="500" y1="170" x2="520" y2="170" stroke="var(--blue)" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Spinner -->
|
||||
<circle class="equip" cx="475" cy="155" r="14"/>
|
||||
<text class="equip-label" x="475" y="159" font-size="8">Spin</text>
|
||||
<g class="anim-overlay fan-anim" id="sim-spinner" transform-origin="475 155" style="animation-duration:0.7s">
|
||||
<line x1="475" y1="143" x2="475" y2="167" stroke="var(--green)" stroke-width="2"/>
|
||||
<line x1="463" y1="155" x2="487" y2="155" stroke="var(--green)" stroke-width="2"/>
|
||||
</g>
|
||||
|
||||
<!-- Discharge -->
|
||||
<rect class="equip" x="580" y="160" width="80" height="60" rx="6"/>
|
||||
<text class="equip-label" x="620" y="195">Discharge</text>
|
||||
|
||||
<!-- Mill -->
|
||||
<rect class="equip" x="580" y="250" width="70" height="50" rx="6"/>
|
||||
<text class="equip-label" x="615" y="280">Mill</text>
|
||||
|
||||
<!-- Shaker -->
|
||||
<rect class="equip" x="670" y="250" width="90" height="50" rx="6"/>
|
||||
<text class="equip-label" x="715" y="280">Shaker Sep.</text>
|
||||
|
||||
<line class="flow-arrow" x1="650" y1="280" x2="665" y2="280"/>
|
||||
<line class="flow-arrow" x1="620" y1="220" x2="620" y2="245"/>
|
||||
|
||||
<!-- Brush -->
|
||||
<rect class="equip" x="670" y="160" width="60" height="40" rx="4"/>
|
||||
<text class="equip-label" x="700" y="184" font-size="9">Brush</text>
|
||||
<line class="flow-arrow" x1="660" y1="190" x2="665" y2="190"/>
|
||||
|
||||
<!-- Labels -->
|
||||
<text x="85" y="150" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">IN</text>
|
||||
<text x="715" y="320" fill="var(--dim)" font-size="10" text-anchor="middle" font-family="sans-serif">OUT</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function updateSchematicAnimations() {
|
||||
// Map card IDs to SVG animation elements
|
||||
const map = {
|
||||
'motor_0': 'sim-fan', 'burner_0': 'sim-burner',
|
||||
'motor_1': 'sim-conveyor', 'motor_2': 'sim-spinner',
|
||||
'motor_3': 'sim-agit1', 'motor_4': 'sim-agit2'
|
||||
};
|
||||
Object.entries(map).forEach(([cardId, svgId]) => {
|
||||
const el = document.getElementById(svgId);
|
||||
if (!el) return;
|
||||
const s = simState[cardId];
|
||||
el.classList.toggle('active', s && s.on);
|
||||
});
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════
|
||||
// BOOT
|
||||
// ══════════════════════════════════════════════════════
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user