Smooth graphs, sun path card, skeleton page switches and editor drag & drop

Firmware 0.2.15: trend graphs use a Catmull-Rom curve with a soft fill
drawn by the card's draw event; sun.sun gets a wide sun-path card with
horizon, arc and the sun at its current position; page switches show
the new page's card frames immediately and fill them from a one-shot
timer; the navigation is one full-width bar without a divider; the CYD
adopts the Guition light palette in styles and overlays.

App 0.2.13: pointer-based drag & drop (mouse and touch) to reorder tiles
in the mockup and to drop entities from the list, sticky save bar, a new
header with explained actions and a three-step guide, clearer firmware
dialog. New sun, weather and clock tiles start wide in their best view.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Max Gramser
2026-09-13 09:09:37 +02:00
parent 517b102786
commit 4af92e1b4c
15 changed files with 807 additions and 321 deletions
+5 -3
View File
@@ -30,7 +30,9 @@ TILE_BACKGROUNDS = {
}
# Display modes per domain; everything else offers standard and watch (large value).
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog')}
DISPLAYS = {'weather': ('standard', 'watch', 'forecast'), 'sensor': ('standard', 'watch', 'graph'), 'screen': ('digital', 'analog'), 'sun': ('standard', 'watch', 'sunpath')}
# Displays that only work on a double-width card.
WIDE_ONLY = ('forecast', 'sunpath')
# Additive schema 1 extension. An absent object retains old firmware/YAML defaults.
SETTING_RULES = {
@@ -114,8 +116,8 @@ def validate_layout(data):
for key, allowed in choices.items():
if key in options and options[key] not in allowed:
raise ValueError('Ongeldige tegelinstelling: ' + key)
# The five-day strip only fits a double-width card.
if options.get('display') == 'forecast':
# The five-day strip and the sun path only fit a double-width card.
if options.get('display') in WIDE_ONLY:
options = {**options, 'size': 'wide'}
if options.get('tap') == 'toggle' and domain not in {'light','switch','input_boolean','fan','media_player'}:
raise ValueError('Deze entiteit ondersteunt geen aan/uit-actie.')
+107 -36
View File
@@ -5,7 +5,6 @@ let inventory = { screens: [], entities: [] },
layout = null,
dirty = false,
filter = "",
dragIndex = -1,
busy = false,
selectedTile = null;
const node = (tag, text, cls) => {
@@ -225,7 +224,15 @@ const domains = {
timer: ["Kookwekker", "⏱", "#008577", "#def3ed"],
person: ["Persoon", "☺", "#2f7d32", "#e1f2e2"],
};
const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok" };
const displayNames = { standard: "standaard", watch: "grote waarde", forecast: "weersvoorspelling", graph: "grafiek", digital: "digitale klok", analog: "analoge klok", sunpath: "zonnebaan" };
// New tiles start with the card that shows the entity best.
function defaultOptions(id) {
const domain = id.split(".")[0];
if (domain === "sun") return { options: { display: "sunpath", size: "wide" } };
if (domain === "weather") return { options: { display: "forecast", size: "wide" } };
if (domain === "screen") return { options: { display: "digital", size: "wide" } };
return {};
}
// Same packing as the firmware: wide tiles start in the left column and take a whole row.
function packTiles(tiles) {
let position = 0;
@@ -283,11 +290,8 @@ function renderPreview() {
if(background)card.style.backgroundColor=background;
if (place.wide) card.classList.add("wide");
card.append(domainBadge(tile.entity), node("strong", name));
card.draggable = true;
card.ondragstart = (e) => { dragIndex = index; e.dataTransfer.setData("text/plain", String(index)); };
card.ondragend = () => { dragIndex = -1; };
card.ondragover = (e) => { if (dragIndex >= 0) e.preventDefault(); };
card.ondrop = (e) => { e.preventDefault(); if (dragIndex >= 0) move(dragIndex, index); };
card.dataset.index = index;
enableDrag(card, { kind: "tile", index });
card.classList.toggle("chosen", selectedTile === tile.entity);
card.setAttribute("aria-label", `Tegel ${index + 1}: ${name}, instellen`);
card.onclick = () => {
@@ -322,27 +326,6 @@ function renderTiles() {
if (i > 0 && placement[i].page !== placement[i - 1].page) $("#tiles").append(node("li", `Pagina ${placement[i].page + 1}`, "page-break"));
const li = node("li", undefined, "tile");
li.classList.toggle("selected-tile", selectedTile === tile.entity);
li.draggable = true;
li.ondragstart = (e) => {
if (e.target.closest("input, select, button")) {
e.preventDefault();
return;
}
dragIndex = i;
e.dataTransfer.setData("text/plain", String(i));
li.classList.add("dragging");
};
li.ondragend = () => {
dragIndex = -1;
li.classList.remove("dragging");
};
li.ondragover = (e) => {
if (dragIndex >= 0) e.preventDefault();
};
li.ondrop = (e) => {
e.preventDefault();
if (dragIndex >= 0) move(dragIndex, i);
};
const content = node("div"),
input = node("input");
input.value = tile.name;
@@ -423,6 +406,7 @@ function renderTiles() {
: [["standard", "Naam en status"], ["watch", "Grote waarde"]];
if (domain === "weather") displays.push(["forecast", "Weersvoorspelling (dubbelbreed)"]);
if (domain === "sensor") displays.push(["graph", "Grafiek van de geschiedenis"]);
if (domain === "sun") displays.push(["sunpath", "Zonnebaan (dubbelbreed)"]);
const fields = [
[
"tap",
@@ -498,7 +482,7 @@ function renderTiles() {
};
if (key === "display" && input.value === "watch")
tile.options.inline = "none";
if (key === "display" && input.value === "forecast")
if (key === "display" && ["forecast", "sunpath"].includes(input.value))
tile.options.size = "wide";
if (key === "inline" && input.value === "slider")
tile.options.display = "standard";
@@ -522,6 +506,97 @@ function renderTiles() {
$("#tiles").append(li);
});
}
function addTile(id, at) {
if (layout.tiles.some((t) => t.entity === id) || layout.tiles.length >= tileLimit()) return;
layout.tiles.splice(Math.min(at, layout.tiles.length), 0, { entity: id, name: "", ...defaultOptions(id) });
selectedTile = id;
markDirty();
renderTiles();
renderResults();
}
// Pointer-based drag & drop: works with mouse and touch, from the picker into
// the mockup and between tiles. Touch starts after a short hold so the page
// still scrolls; a finished drag never doubles as a click.
const drag = { active: false, source: null, element: null, ghost: null, target: null, timer: 0, start: null, offset: null, pointerId: null, suppressUntil: 0, last: null, scroller: 0 };
function enableDrag(element, source) {
element.addEventListener("pointerdown", (e) => {
if (e.button !== 0 || element.disabled) return;
Object.assign(drag, { source, element, start: { x: e.clientX, y: e.clientY }, pointerId: e.pointerId, active: false });
clearTimeout(drag.timer);
if (e.pointerType === "touch") drag.timer = setTimeout(() => beginDrag(e), 260);
});
element.addEventListener("pointermove", (e) => {
if (!drag.start || drag.element !== element) return;
const distance = Math.hypot(e.clientX - drag.start.x, e.clientY - drag.start.y);
if (!drag.active) {
if (e.pointerType === "touch") { if (distance > 10) { clearTimeout(drag.timer); drag.start = null; } return; }
if (distance < 6) return;
beginDrag(e);
}
moveDrag(e);
});
const finish = (e) => {
if (drag.element !== element) return;
clearTimeout(drag.timer);
if (drag.active) endDrag(e.type === "pointerup");
drag.start = null;
};
element.addEventListener("pointerup", finish);
element.addEventListener("pointercancel", finish);
}
function beginDrag(e) {
if (drag.active || !drag.start) return;
drag.active = true;
try { drag.element.setPointerCapture(drag.pointerId); } catch {}
const rect = drag.element.getBoundingClientRect();
const ghost = drag.element.cloneNode(true);
ghost.classList.add("drag-ghost");
ghost.style.width = `${rect.width}px`;
drag.offset = { x: e.clientX - rect.left, y: e.clientY - rect.top };
document.body.append(ghost);
drag.ghost = ghost;
document.body.classList.add("dragging");
document.addEventListener("touchmove", blockScroll, { passive: false });
// Near the viewport edges the page scrolls along, so the mockup can be reached on small screens.
drag.scroller = setInterval(() => {
if (!drag.last) return;
const step = drag.last.y < 70 ? -12 : drag.last.y > innerHeight - 70 ? 12 : 0;
if (step) { window.scrollBy(0, step); setTarget(document.elementFromPoint(drag.last.x, drag.last.y)?.closest(".preview-tile, .preview-gap, .preview-grid") || null); }
}, 16);
moveDrag(e);
}
function blockScroll(e) { if (drag.active) e.preventDefault(); }
function moveDrag(e) {
if (!drag.ghost) return;
drag.last = { x: e.clientX, y: e.clientY };
drag.ghost.style.transform = `translate(${e.clientX - drag.offset.x}px, ${e.clientY - drag.offset.y}px)`;
const under = document.elementFromPoint(e.clientX, e.clientY);
setTarget(under?.closest(".preview-tile, .preview-gap, .preview-grid") || null);
}
function setTarget(target) {
if (drag.target === target) return;
drag.target?.classList.remove("drop-target");
drag.target = target;
drag.target?.classList.add("drop-target");
}
function endDrag(drop) {
const { target, source } = drag;
setTarget(null);
drag.ghost?.remove();
drag.ghost = null;
drag.active = false;
drag.suppressUntil = Date.now() + 400;
clearInterval(drag.scroller);
drag.last = null;
document.body.classList.remove("dragging");
document.removeEventListener("touchmove", blockScroll);
try { drag.element.releasePointerCapture(drag.pointerId); } catch {}
if (!drop || !target) return;
const to = target.dataset.index !== undefined ? Number(target.dataset.index) : layout.tiles.length;
if (source.kind === "tile") { if (to !== source.index) move(source.index, to); }
else addTile(source.id, to);
}
window.addEventListener("click", (e) => { if (Date.now() < drag.suppressUntil) { e.stopPropagation(); e.preventDefault(); } }, true);
function renderResults() {
if (!layout) return;
const query = $("#search").value.toLocaleLowerCase();
@@ -550,13 +625,8 @@ function renderResults() {
node("span", chosen.has(entity.id) ? "✓" : "+", "plus"),
);
b.disabled = chosen.has(entity.id) || layout.tiles.length >= tileLimit();
b.onclick = () => {
layout.tiles.push({ entity: entity.id, name: "" });
selectedTile = entity.id;
markDirty();
renderTiles();
renderResults();
};
b.onclick = () => addTile(entity.id, layout.tiles.length);
if (!b.disabled) enableDrag(b, { kind: "entity", id: entity.id });
$("#results").append(b);
}
if (!matches.length)
@@ -845,6 +915,7 @@ function openSection(id) {
section.scrollIntoView({ behavior: "smooth", block: "start" });
}
$("#nav-tiles").onclick = () => openSection("#tile-section");
$("#open-help").onclick = () => openSection("#help");
$("#nav-settings").onclick = () => openSection("#general-settings");
$("#nav-inspector").onclick = () => inspect();
async function inspect(entity) {
+65 -12
View File
@@ -12,11 +12,29 @@
<div class="brand">
<span class="mark">▦</span>
<div>
<strong>ESP Screens</strong><small>Je huis. Jouw bediening.</small>
<strong>ESP Screens</strong
><small>Tegels voor je CYD en Guition, zonder opnieuw flashen</small>
</div>
</div>
<button id="open-firmware" class="quiet">Firmware & USB</button>
<button id="new-screen">+ Nieuw scherm</button>
<nav class="toolbar" aria-label="Acties">
<button id="new-screen" class="tool tool-primary">
<span class="tool-icon" aria-hidden="true">+</span>
<span class="tool-text"
><b>Nieuw scherm</b><small>Koppel een CYD of Guition</small></span
>
</button>
<button id="open-firmware" class="tool">
<span class="tool-icon" aria-hidden="true">⇪</span>
<span class="tool-text"
><b>Firmware & USB</b
><small>Bouwen en installeren via USB of wifi</small></span
>
</button>
<button id="open-help" class="tool">
<span class="tool-icon" aria-hidden="true">?</span>
<span class="tool-text"><b>Uitleg</b><small>Hoe werkt het</small></span>
</button>
</nav>
</header>
<main>
<div class="intro">
@@ -27,6 +45,26 @@
</div>
<span id="connection" role="status" class="badge">Verbinden…</span>
</div>
<ol class="howto" aria-label="Zo werkt het">
<li>
<b>1 · Koppelen</b><span
>Kies <b>Nieuw scherm</b> voor de installatie-YAML met eigen
sleutels. Eén keer via USB flashen, daarna gaat alles draadloos.</span
>
</li>
<li>
<b>2 · Tegels kiezen</b><span
>Sleep entiteiten uit de lijst rechts in de schermmockup, of tik
op +. Klik op een tegel voor weergave, breedte en kleur.</span
>
</li>
<li>
<b>3 · Opslaan</b><span
>Met <b>Opslaan & naar scherm</b> staat het direct op je scherm.
Nieuwe schermfuncties installeer je via <b>Firmware & USB</b>.</span
>
</li>
</ol>
<div class="workspace">
<aside>
<h2>Mijn schermen</h2>
@@ -136,7 +174,7 @@
<b>Firmware & USB</b> bovenaan. Je bestaande YAML en secrets blijven
bewaard.
</p>
<details class="help">
<details class="help" id="help">
<summary>Hoe werkt het dagelijks?</summary>
<p>
Zoek een entiteit, voeg deze toe en klik op
@@ -147,9 +185,18 @@
waarde, mini-schuif of klikgedrag.
</p>
<p>
Nieuwe firmware bouw en installeer je hier met de ingebouwde ESPHome
CLI. Je tegels blijven in deze app bewaard. Een offline scherm krijgt
de wijzigingen zodra het terug is.
Speciale kaarten: de <b>Klok</b> staat bovenaan de kiezer; een
weerentiteit toont de vijfdaagse voorspelling, <code>sun.sun</code>
de zonnebaan, een sensor een trendgrafiek, een <code>timer</code>
telt live af en een <code>person</code> toont thuis/weg. Zet een
tegel op <b>Dubbelbreed</b> voor meer ruimte.
</p>
<p>
Nieuwe firmware bouw en installeer je via <b>Firmware & USB</b> met
de ingebouwde ESPHome CLI: kies je eigen profiel, dan een USB-poort of
wifi, en klik op <b>Bouwen & installeren</b>. Je tegels blijven in
deze app bewaard; een offline scherm krijgt de wijzigingen zodra het
terug is.
</p>
</details>
</main>
@@ -271,13 +318,19 @@
</dialog>
<dialog id="firmware-dialog">
<div class="dialog-head">
<h2>Firmware & USB</h2>
<button id="close-firmware" class="quiet">Sluiten</button>
<div>
<p class="eyebrow">FIRMWARE & USB</p>
<h2>Schermfirmware bouwen en installeren</h2>
</div>
<button id="close-firmware" class="quiet" aria-label="Sluiten">✕</button>
</div>
<p>
De ingebouwde ESPHome CLI gebruikt je eigen YAML en secrets. Tegels
blijven in ESP Screens staan. Op de Raspberry kan de eerste build enkele
minuten duren.
De ingebouwde ESPHome CLI gebruikt je eigen profiel (YAML met sleutels)
en secrets. Kies het profiel van het scherm, dan een USB-poort (scherm
aan de Home Assistant-machine) of <b>Wifi / OTA</b> met de hostnaam.
<b>Controleren</b> valideert alleen, <b>Alleen bouwen</b> compileert,
<b>Bouwen & installeren</b> zet de firmware op het scherm. Je tegels
blijven in ESP Screens staan. De eerste build duurt enkele minuten.
</p>
<label>ESPHome-profiel<select id="firmware-file"></select></label>
<label
+112 -2
View File
@@ -184,6 +184,98 @@ aside h2 {
color: var(--muted);
padding: 7px;
}
.toolbar {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.tool {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 14px 8px 9px;
border: 1px solid var(--line);
border-radius: 14px;
background: white;
color: var(--ink);
text-align: left;
}
.tool:hover {
border-color: #b9cbec;
box-shadow: 0 3px 10px #202c3c0c;
}
.tool-icon {
width: 34px;
height: 34px;
border-radius: 10px;
background: var(--soft);
color: var(--accent);
display: grid;
place-items: center;
font-size: 19px;
font-weight: 700;
flex: none;
}
.tool-primary .tool-icon {
background: var(--accent);
color: white;
}
.tool-text b {
display: block;
font-size: 13px;
}
.tool-text small {
color: var(--muted);
font-size: 11px;
}
.howto {
list-style: none;
padding: 0;
margin: 0 0 26px;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.howto li {
background: white;
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
font-size: 12px;
color: var(--muted);
line-height: 1.45;
}
.howto li > b {
display: block;
color: var(--ink);
font-size: 13px;
margin-bottom: 4px;
}
.preview-tile,
.result {
touch-action: pan-y;
cursor: grab;
}
.drag-ghost {
position: fixed;
left: 0;
top: 0;
z-index: 1000;
pointer-events: none;
opacity: 0.92;
box-shadow: 0 14px 32px #202c3c33;
background: white;
margin: 0;
}
.drop-target {
outline: 2px solid var(--accent);
outline-offset: 2px;
background: var(--soft) !important;
}
body.dragging {
cursor: grabbing;
user-select: none;
}
#editor,
.empty {
background: white;
@@ -331,9 +423,15 @@ aside h2 {
font-size: 22px;
}
.savebar {
margin-top: 25px;
padding-top: 20px;
position: sticky;
bottom: 0;
z-index: 6;
margin: 25px -27px -27px;
padding: 16px 27px;
background: #ffffffee;
backdrop-filter: blur(6px);
border-top: 1px solid var(--line);
border-radius: 0 0 18px 18px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -475,10 +573,22 @@ dialog label {
margin-bottom: 10px;
}
}
@media (max-width: 1000px) {
.howto {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
main {
padding: 25px 16px;
}
.tool-text small {
display: none;
}
.savebar {
margin: 25px -18px -18px;
padding: 14px 18px;
}
.workspace {
display: block;
}