Compare commits

...

7 Commits
backup ... main

  1. 0
      Specification.rst
  2. 2
      php/monoformat_structured.php
  3. 29
      php/schedule.php
  4. 19
      ts-editor/index.html
  5. 68
      ts-editor/mobile.css
  6. 102
      ts-editor/src/browser.ts
  7. 33
      ts-editor/style.css
  8. 5
      ts/src/driver.ts

@ -26,7 +26,7 @@ function serializePixels(array $pixels): string {
function unserializePixels(int $n, string $serialized): array { function unserializePixels(int $n, string $serialized): array {
$nBytes = intdiv($n + 7, 8); $nBytes = intdiv($n + 7, 8);
if (strlen($serialized) != $nBytes) { if (strlen($serialized) != $nBytes) {
$actual = strlen($serializd); $actual = strlen($serialized);
throw new \ValueError("Could not unserialize a pixel bitmap: the size $actual does not equal the expected $nBytes"); throw new \ValueError("Could not unserialize a pixel bitmap: the size $actual does not equal the expected $nBytes");
} }
$result = array_fill(0, $n, false); $result = array_fill(0, $n, false);

@ -1,5 +1,11 @@
<?php <?php
$roomMapping = [
];
$passThroughMapping = [
];
include_once("monoformat_schema.php"); include_once("monoformat_schema.php");
include_once("monoformat_structured.php"); include_once("monoformat_structured.php");
@ -23,8 +29,27 @@ function replaceTalkInfo($str, $talks) {
}, $str); }, $str);
} }
$roomMapping = [ if (isset($_GET["mac"]) and array_key_exists($_GET["mac"], $passThroughMapping)) {
]; $fn = $passThroughMapping[$_GET["mac"]];
if (is_dir($fn)) {
$d = opendir($fn);
$files = [];
while (($e = readdir($d)) !== false) {
if (str_ends_with($e, ".bin")) {
array_push($files, $fn . "/" . $e);
}
}
sort($files);
if (!count($files)) {
die("No files found");
}
$n = (time() / 60) % count($files);
$fn = $files[$n];
}
Header("Content-Type: application/octet-stream");
readfile($fn);
exit(0);
}
if (!isset($_GET["mac"]) or !array_key_exists($_GET["mac"], $roomMapping)) { if (!isset($_GET["mac"]) or !array_key_exists($_GET["mac"], $roomMapping)) {
/* We didn't find this device in the mapping, so let's just /* We didn't find this device in the mapping, so let's just

@ -7,10 +7,10 @@
<title>MonoDisplay Editor</title> <title>MonoDisplay Editor</title>
<script>!function () { var t = localStorage.getItem('theme'); t && document.documentElement.setAttribute('data-theme', t) }();</script> <script>!function () { var t = localStorage.getItem('theme'); t && document.documentElement.setAttribute('data-theme', t) }();</script>
<link href="style.css" rel="stylesheet" /> <link href="style.css" rel="stylesheet" />
<link href="mobile.css" rel="stylesheet" />
</head> </head>
<body> <body>
<div id="topbar"> <div id="topbar">
<span class="app-name">MonoDisplay</span> <span class="app-name">MonoDisplay</span>
<div class="tb-sep"></div> <div class="tb-sep"></div>
@ -52,10 +52,13 @@
<div class="tb-sep"></div> <div class="tb-sep"></div>
<button class="tb-btn" id="theme-toggle" onclick="cycleTheme()">⊙ auto</button> <button class="tb-btn" id="theme-toggle" onclick="cycleTheme()">⊙ auto</button>
</div> </div>
<div id="main"> <div id="main">
<div id="left"> <div id="left">
<span class="pv-label">preview · 120 × 60</span> <span class="pv-label">preview · <select onchange="changeDisplayLayout()">
<option>120 × 60</option>
<option>120 × 40</option>
</select></span>
</span>
<div id="display-box"> <div id="display-box">
<canvas id="canvas_root" width="120" height="60"></canvas> <canvas id="canvas_root" width="120" height="60"></canvas>
</div> </div>
@ -87,25 +90,27 @@
</div> </div>
</div> </div>
</div> </div>
<div id="right"> <div id="right">
<div id="right-hdr"> <div id="right-hdr">
<span class="rh-title">sections</span> <span class="rh-title">sections</span>
<button class="tb-btn" id="mob-add-section" onclick="addSection()">
<svg viewBox="0 0 24 24">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div> </div>
<div id="sections-wrap"> <div id="sections-wrap">
<div class="empty-state">No sections yet.<br />Use <b>Add section</b> to get started.</div> <div class="empty-state">No sections yet.<br />Use <b>Add section</b> to get started.</div>
</div> </div>
</div> </div>
</div> </div>
<!-- confirm dialog -->
<div id="confirm-overlay"> <div id="confirm-overlay">
<div id="confirm-box"> <div id="confirm-box">
<div id="confirm-msg"></div> <div id="confirm-msg"></div>
<div id="confirm-btns"></div> <div id="confirm-btns"></div>
</div> </div>
</div> </div>
<script src="./public/mono-display.js"></script> <script src="./public/mono-display.js"></script>
</body> </body>

@ -0,0 +1,68 @@
@media (max-width: 640px) {
#topbar {
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
gap: 6px;
padding: 0 8px;
}
#topbar::-webkit-scrollbar {
display: none;
}
#topbar>* {
flex-shrink: 0;
}
#topbar .tb-btn {
font-size: 0;
gap: 0;
padding: 3px 8px;
}
#topbar .tb-btn svg {
width: 15px;
height: 15px;
}
#topbar #theme-toggle {
font-size: 11px;
}
#topbar button[onclick="addSection()"] {
display: none;
}
#mob-add-section {
display: flex;
}
#topbar [style*="flex:1"] {
display: none;
}
#main {
flex-direction: column;
overflow-y: auto;
}
#left,
#right {
width: 100%;
border-right: none;
flex-shrink: 0;
}
#left {
border-bottom: 1px solid var(--bd-inner);
padding: 10px;
gap: 8px;
}
#display-box {
aspect-ratio: 2/1;
width: 100%;
}
}

@ -26,7 +26,7 @@ import {
} from "libmonoformat"; } from "libmonoformat";
const W = 120, H = 60; let W = 120, H = 60;
// --- State ------------------------------------------------------------------- // --- State -------------------------------------------------------------------
let file: MonoDisplayFile = new MonoDisplayFile([]); let file: MonoDisplayFile = new MonoDisplayFile([]);
@ -185,6 +185,15 @@ async function guardDirty(): Promise<boolean> {
); );
} }
function changeDisplayLayout() {
const sel = document.querySelector('.pv-label select') as HTMLSelectElement;
const [w, h] = sel.value.split('×').map(s => parseInt(s.trim()));
W = w as number;
H = h as number;
((window as any)._mdDriver as MonoDisplayDriver).setSize(W, H);
((window as any)._mdDriver as MonoDisplayDriver).load(() => Promise.resolve(file.toBuffer()));
}
// --- Helpers ----------------------------------------------------------------- // --- Helpers -----------------------------------------------------------------
function newSec(): MonoFormatElementsAlways { function newSec(): MonoFormatElementsAlways {
return { return {
@ -425,7 +434,7 @@ function triggerPreview() {
function buildPreview() { function buildPreview() {
if (!(window as any)._mdDriver) if (!(window as any)._mdDriver)
(window as any)._mdDriver = new MonoDisplayDriver("canvas_root", { onColor: "#EC0", offColor: "#000", fps: 25 }); (window as any)._mdDriver = new MonoDisplayDriver("canvas_root", { onColor: "#EC0", offColor: "#000", fps: 25 });
(window as any)._mdDriver.load(() => Promise.resolve(file.toBuffer())); changeDisplayLayout();
} }
// --- Load / Export ----------------------------------------------------------- // --- Load / Export -----------------------------------------------------------
@ -518,82 +527,99 @@ const PixelCanvas: m.Component<{ img: MonoFormatPixelImage; onpaint: () => void
vnode.state.drawing = false; vnode.state.drawing = false;
vnode.state.drawValue = 1; vnode.state.drawValue = 1;
}, },
view({ attrs: { img, onpaint }, state }) { view({ attrs: { img, onpaint }, state: s }) {
function pixelFromEvent(e: MouseEvent): { x: number; y: number } | null { function pixelFromCoords(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
const canvas = e.currentTarget as HTMLCanvasElement;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width; const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height; const scaleY = canvas.height / rect.height;
const x = Math.floor((e.clientX - rect.left) * scaleX / PIXEL_SCALE); const x = Math.floor((clientX - rect.left) * scaleX / PIXEL_SCALE);
const y = Math.floor((e.clientY - rect.top) * scaleY / PIXEL_SCALE); const y = Math.floor((clientY - rect.top) * scaleY / PIXEL_SCALE);
if (x < 0 || x >= img.width || y < 0 || y >= img.height) return null; if (x < 0 || x >= img.width || y < 0 || y >= img.height) return null;
return { x, y }; return { x, y };
} }
function paint(e: MouseEvent) { function paintAt(canvas: HTMLCanvasElement, clientX: number, clientY: number) {
const p = pixelFromEvent(e); const p = pixelFromCoords(canvas, clientX, clientY);
if (!p) return; if (!p) return;
img.pixels[getPixelIndex(img, p.x, p.y)] = state.drawValue; img.pixels[getPixelIndex(img, p.x, p.y)] = s.drawValue;
onpaint(); onpaint();
drawPixelCanvas(e.currentTarget as HTMLCanvasElement, img); drawPixelCanvas(canvas, img);
}
function paint(e: MouseEvent) {
paintAt(e.currentTarget as HTMLCanvasElement, e.clientX, e.clientY);
}
function attachTouch(canvas: HTMLCanvasElement) {
canvas.addEventListener("touchstart", (e: TouchEvent) => {
e.preventDefault();
s.drawing = true;
const touch = e.changedTouches[0];
const p = pixelFromCoords(canvas, touch.clientX, touch.clientY);
if (p) s.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1;
paintAt(canvas, touch.clientX, touch.clientY);
}, { passive: false });
canvas.addEventListener("touchmove", (e: TouchEvent) => {
e.preventDefault();
if (!s.drawing) return;
const touch = e.changedTouches[0];
paintAt(canvas, touch.clientX, touch.clientY);
}, { passive: false });
canvas.addEventListener("touchend", (e) => { e.preventDefault(); s.drawing = false; }, { passive: false });
canvas.addEventListener("touchcancel", (e) => { e.preventDefault(); s.drawing = false; }, { passive: false });
} }
return m("canvas.pixel-editor", { return m("canvas.pixel-editor", {
width: img.width * PIXEL_SCALE, width: img.width * PIXEL_SCALE,
height: img.height * PIXEL_SCALE, height: img.height * PIXEL_SCALE,
oncreate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img), oncreate: ({ dom }: m.VnodeDOM) => {
const canvas = dom as HTMLCanvasElement;
drawPixelCanvas(canvas, img);
attachTouch(canvas);
},
onupdate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img), onupdate: ({ dom }: m.VnodeDOM) => drawPixelCanvas(dom as HTMLCanvasElement, img),
onmousedown: (e: MouseEvent) => { onmousedown: (e: MouseEvent) => {
state.drawing = true; s.drawing = true;
const p = pixelFromEvent(e); const p = pixelFromCoords(e.currentTarget as HTMLCanvasElement, e.clientX, e.clientY);
if (p) state.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1; if (p) s.drawValue = img.pixels[getPixelIndex(img, p.x, p.y)] ? 0 : 1;
paint(e); paint(e);
}, },
onmousemove: (e: MouseEvent) => { if (state.drawing) paint(e); }, onmousemove: (e: MouseEvent) => { if (s.drawing) paint(e); },
onmouseup: () => { state.drawing = false; }, onmouseup: () => { s.drawing = false; },
onmouseleave: () => { state.drawing = false; }, onmouseleave: () => { s.drawing = false; },
ondragover: (e: DragEvent) => { ondragover: (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
e.dataTransfer!.dropEffect = "copy"; e.dataTransfer!.dropEffect = "copy";
}, },
ondrop: (e: DragEvent) => { ondrop: (e: DragEvent) => {
e.preventDefault(); e.preventDefault();
const file = e.dataTransfer?.files[0]; const file = e.dataTransfer?.files[0];
if (!file || !file.type.startsWith("image/")) return; if (!file || !file.type.startsWith("image/")) return;
const url = URL.createObjectURL(file); const url = URL.createObjectURL(file);
const htmlImg = new Image(); const htmlImg = new Image();
htmlImg.onload = () => { htmlImg.onload = () => {
// Draw the dropped image into an offscreen canvas to read pixel data
const offscreen = document.createElement("canvas"); const offscreen = document.createElement("canvas");
offscreen.width = htmlImg.width;
offscreen.height = htmlImg.height;
const ctx = offscreen.getContext("2d")!; const ctx = offscreen.getContext("2d")!;
ctx.drawImage(htmlImg, 0, 0);
const imageData = ctx.getImageData(0, 0, htmlImg.width, htmlImg.height);
// Convert to your pixel format — adjust this to match your img structure
const scale = Math.min(1, 120 / htmlImg.width, 60 / htmlImg.height); const scale = Math.min(1, 120 / htmlImg.width, 60 / htmlImg.height);
const w = Math.round(htmlImg.width * scale); const w = Math.round(htmlImg.width * scale);
const h = Math.round(htmlImg.height * scale); const h = Math.round(htmlImg.height * scale);
offscreen.width = w; offscreen.width = w;
offscreen.height = h; offscreen.height = h;
ctx.drawImage(htmlImg, 0, 0, w, h); // scales the image down while drawing ctx.drawImage(htmlImg, 0, 0, w, h);
const imageData = ctx.getImageData(0, 0, w, h);
img.width = w; img.width = w;
img.height = h; img.height = h;
// then read imageData from the scaled offscreen canvas as before
img.pixels = new Uint8Array(w * h).map((_, i) => { img.pixels = new Uint8Array(w * h).map((_, i) => {
const r = imageData.data[i * 4] || 0; const r = imageData.data[i * 4];
const g = imageData.data[i * 4 + 1] || 0; const g = imageData.data[i * 4 + 1];
const b = imageData.data[i * 4 + 2] || 0; const b = imageData.data[i * 4 + 2];
const a = imageData.data[i * 4 + 3] || 0; const a = imageData.data[i * 4 + 3];
// transparent = 0, dark pixels = 1, light pixels = 0
return a > 128 && (r * 299 + g * 587 + b * 114) < 128_000 ? 0 : 1; return a > 128 && (r * 299 + g * 587 + b * 114) < 128_000 ? 0 : 1;
}); });
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
m.redraw(); m.redraw();
}; };
@ -916,7 +942,7 @@ async function clearAll() {
} }
// Expose globals referenced by topbar inline handlers // Expose globals referenced by topbar inline handlers
Object.assign(window, { loadBin, exportBin, addSection, clearAll, cycleTheme }); Object.assign(window, { loadBin, exportBin, addSection, clearAll, cycleTheme, changeDisplayLayout });
// window.addEventListener("beforeunload", (e) => { // window.addEventListener("beforeunload", (e) => {
// if (isDirty) e.preventDefault(); // if (isDirty) e.preventDefault();

@ -252,6 +252,7 @@ body {
gap: 5px; gap: 5px;
margin-top: 6px margin-top: 6px
} }
#demo-btns>div { #demo-btns>div {
display: contents; display: contents;
} }
@ -263,17 +264,20 @@ canvas.pixel-editor {
max-width: 100%; max-width: 100%;
border: 1px solid var(--bd-inner); border: 1px solid var(--bd-inner);
} }
.anim-editor { .anim-editor {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
} }
.anim-frame-tabs { .anim-frame-tabs {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 3px; gap: 3px;
align-items: center; align-items: center;
} }
.anim-frame-tab { .anim-frame-tab {
display: flex; display: flex;
align-items: center; align-items: center;
@ -285,10 +289,12 @@ canvas.pixel-editor {
font-size: 11px; font-size: 11px;
user-select: none; user-select: none;
} }
.anim-frame-tab.active { .anim-frame-tab.active {
border-color: var(--accent, #EC0); border-color: var(--accent, #EC0);
color: var(--accent, #EC0); color: var(--accent, #EC0);
} }
.x-btn-sm { .x-btn-sm {
background: none; background: none;
border: none; border: none;
@ -299,7 +305,10 @@ canvas.pixel-editor {
line-height: 1; line-height: 1;
opacity: 0.6; opacity: 0.6;
} }
.x-btn-sm:hover { opacity: 1; }
.x-btn-sm:hover {
opacity: 1;
}
#sec-meta { #sec-meta {
background: var(--bg-sunken); background: var(--bg-sunken);
@ -680,3 +689,25 @@ select.meta-val {
.cb.primary:hover { .cb.primary:hover {
background: var(--bg-active) background: var(--bg-active)
} }
#mob-add-section {
display: none;
}
canvas.pixel-editor {
touch-action: none;
/* belt-and-suspenders alongside preventDefault */
-webkit-user-select: none;
user-select: none;
}
.pv-label select {
background: var(--bg-input);
color: var(--ac);
border: 1px solid var(--bd-inner);
border-radius: 3px;
padding: 1px 14px;
font-family: monospace;
font-size: 10px;
letter-spacing: .1em;
}

@ -88,6 +88,11 @@ export class MonoDisplayDriver {
} }
} }
setSize(w: number, h: number) {
this.opts.displayHeight = h;
this.opts.displayWidth = w;
}
stop(): void { this.renderer?.stop(); } stop(): void { this.renderer?.stop(); }
} }

Loading…
Cancel
Save