Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
70c29b12ea | 6 days ago |
@ -0,0 +1,3 @@ |
||||
RewriteEngine on |
||||
RewriteCond %{REQUEST_URI} \.bin$ [NC] |
||||
RewriteRule ^(.*\.bin)$ /busanzeiger-webinterface/src/show.php [L,QSA] |
||||
@ -0,0 +1 @@ |
||||
Schildkroete.bin |
||||
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 106 KiB |
@ -0,0 +1,165 @@ |
||||
// Binary Framebuffer Encoder
|
||||
// Converts pixel data to binary format for bus display
|
||||
|
||||
const MAGIC = [66, 78, 23, 238]; |
||||
const DrawEntryType = { |
||||
Image: 1, |
||||
Animation: 2, |
||||
HScroll: 3, |
||||
VScroll: 4, |
||||
Line: 5 |
||||
}; |
||||
|
||||
function bitmapSizeBytes(width, height) { |
||||
return Math.floor((width * height + 7) / 8); |
||||
} |
||||
|
||||
function setPixelInBitmap(x, y, memory, width, height, value) { |
||||
const index = y * width + x; |
||||
const byteIndex = Math.floor(index / 8); |
||||
const bitIndex = index % 8; |
||||
if (value) { |
||||
memory[byteIndex] |= 1 << bitIndex; |
||||
} else { |
||||
memory[byteIndex] &= ~(1 << bitIndex); |
||||
} |
||||
} |
||||
|
||||
class BinaryFramebufferEncoder { |
||||
entries = []; |
||||
|
||||
addImage(posX, posY, width, height, pixelData) { |
||||
const data = new Uint8Array(bitmapSizeBytes(width, height)); |
||||
for (let y = 0; y < height; y++) { |
||||
for (let x = 0; x < width; x++) { |
||||
setPixelInBitmap(x, y, data, width, height, pixelData[y][x]); |
||||
} |
||||
} |
||||
this.entries.push({ |
||||
type: DrawEntryType.Image, |
||||
header: { posX, posY }, |
||||
width, |
||||
height, |
||||
data |
||||
}); |
||||
} |
||||
|
||||
addAnimation(posX, posY, width, height, frameCount, updateInterval, frames) { |
||||
const frameSize = bitmapSizeBytes(width, height); |
||||
const data = new Uint8Array(frameSize * frameCount); |
||||
for (let f = 0; f < frameCount; f++) { |
||||
const frameData = data.subarray(f * frameSize, (f + 1) * frameSize); |
||||
for (let y = 0; y < height; y++) { |
||||
for (let x = 0; x < width; x++) { |
||||
setPixelInBitmap(x, y, frameData, width, height, frames[f][y][x]); |
||||
} |
||||
} |
||||
} |
||||
this.entries.push({ |
||||
type: DrawEntryType.Animation, |
||||
header: { posX, posY }, |
||||
width, |
||||
height, |
||||
frameCount, |
||||
updateInterval, |
||||
data |
||||
}); |
||||
} |
||||
|
||||
encode() { |
||||
let totalSize = 5; |
||||
for (const entry of this.entries) { |
||||
totalSize += 3; |
||||
switch (entry.type) { |
||||
case DrawEntryType.Image: |
||||
totalSize += 2 + entry.data.length; |
||||
break; |
||||
case DrawEntryType.Animation: |
||||
totalSize += 4 + entry.data.length; |
||||
break; |
||||
} |
||||
} |
||||
const buffer = new Uint8Array(totalSize); |
||||
let pos = 0; |
||||
buffer.set(MAGIC, pos); |
||||
pos += 4; |
||||
buffer[pos++] = this.entries.length; |
||||
for (const entry of this.entries) { |
||||
buffer[pos++] = entry.type; |
||||
buffer[pos++] = entry.header.posX; |
||||
buffer[pos++] = entry.header.posY; |
||||
switch (entry.type) { |
||||
case DrawEntryType.Image: |
||||
buffer[pos++] = entry.width; |
||||
buffer[pos++] = entry.height; |
||||
buffer.set(entry.data, pos); |
||||
pos += entry.data.length; |
||||
break; |
||||
case DrawEntryType.Animation: |
||||
buffer[pos++] = entry.width; |
||||
buffer[pos++] = entry.height; |
||||
buffer[pos++] = entry.frameCount; |
||||
buffer[pos++] = entry.updateInterval; |
||||
buffer.set(entry.data, pos); |
||||
pos += entry.data.length; |
||||
break; |
||||
} |
||||
} |
||||
return buffer; |
||||
} |
||||
} |
||||
|
||||
function parseJSONFrames(text, width = 120, height = 60, maxFrames = 50) { |
||||
const json = JSON.parse(text); |
||||
if (!Array.isArray(json)) { |
||||
throw new Error("JSON must be an array of frames"); |
||||
} |
||||
const frames = []; |
||||
for (const frame of json) { |
||||
if (!Array.isArray(frame)) { |
||||
throw new Error("Each frame must be an array of coordinates"); |
||||
} |
||||
const pixels = Array(height).fill(null).map(() => Array(width).fill(false)); |
||||
for (const change of frame) { |
||||
const [x, y] = change; |
||||
if (y < pixels.length && x < pixels[y].length) { |
||||
pixels[y][x] = true; |
||||
} |
||||
} |
||||
if (frames.length >= maxFrames) continue; |
||||
frames.push(pixels); |
||||
} |
||||
return frames; |
||||
} |
||||
|
||||
function jsonToBinary(jsonString, options = {}) { |
||||
const { |
||||
startX = 0, |
||||
startY = 0, |
||||
updateInterval = 3, |
||||
width = 120, |
||||
height = 60, |
||||
maxFrames = 50 |
||||
} = options; |
||||
const encoder = new BinaryFramebufferEncoder(); |
||||
const frames = parseJSONFrames(jsonString, width, height, maxFrames); |
||||
if (frames.length === 1) { |
||||
encoder.addImage(startX, startY, width, height, frames[0]); |
||||
} else { |
||||
encoder.addAnimation(startX, startY, width, height, frames.length, updateInterval, frames); |
||||
} |
||||
return encoder.encode(); |
||||
} |
||||
|
||||
// Helper function to drop every nth frame from a frame array
|
||||
function dropEveryNthFrame(frames, n) { |
||||
if (n <= 1 || frames.length <= 1) return frames; |
||||
return frames.filter((frame, index) => (index + 1) % n !== 0); |
||||
} |
||||
|
||||
// Helper function to keep 1 frame, drop 2 frames in repeating pattern
|
||||
// Pattern: keep, drop, drop, keep, drop, drop, ...
|
||||
function keepOneDropTwo(frames) { |
||||
if (frames.length <= 1) return frames; |
||||
return frames.filter((frame, index) => index % 3 === 0); |
||||
} |
||||
@ -0,0 +1,112 @@ |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
|
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> |
||||
<title>MonoDisplay Editor</title> |
||||
<script>!function(){var t=localStorage.getItem('theme');t&&document.documentElement.setAttribute('data-theme',t)}();</script> |
||||
<link href="style.css" rel="stylesheet"/> |
||||
</head> |
||||
|
||||
<body> |
||||
|
||||
<div id="topbar"> |
||||
<span class="app-name">MonoDisplay</span> |
||||
<div class="tb-sep"></div> |
||||
<button class="tb-btn" onclick="document.getElementById('file-input').click()"> |
||||
<svg viewBox="0 0 24 24"> |
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> |
||||
<polyline points="17 8 12 3 7 8" /> |
||||
<line x1="12" y1="3" x2="12" y2="15" /> |
||||
</svg> |
||||
Load .bin |
||||
</button> |
||||
<input type="file" id="file-input" accept=".bin" onchange="loadBin(this)" /> |
||||
<button class="tb-btn" onclick="exportBin()"> |
||||
<svg viewBox="0 0 24 24"> |
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /> |
||||
<polyline points="7 10 12 15 17 10" /> |
||||
<line x1="12" y1="15" x2="12" y2="3" /> |
||||
</svg> |
||||
Export .bin |
||||
</button> |
||||
<div class="tb-sep"></div> |
||||
<button class="tb-btn" onclick="clearAll()"> |
||||
<svg viewBox="0 0 24 24"> |
||||
<polyline points="3 6 5 6 21 6" /> |
||||
<path d="M19 6l-1 14H6L5 6" /> |
||||
<path d="M10 11v6M14 11v6" /> |
||||
</svg> |
||||
Clear all |
||||
</button> |
||||
<button class="tb-btn" 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> |
||||
Add section |
||||
</button> |
||||
<span id="filename">untitled</span> |
||||
<div style="flex:1"></div> |
||||
<div class="tb-sep"></div> |
||||
<button class="tb-btn" id="theme-toggle" onclick="cycleTheme()">⊙ auto</button> |
||||
</div> |
||||
|
||||
<div id="main"> |
||||
<div id="left"> |
||||
<span class="pv-label">preview · 120 × 60</span> |
||||
<div id="display-box"> |
||||
<canvas id="canvas_root" width="120" height="60"></canvas> |
||||
</div> |
||||
<span class="pv-label">demos</span> |
||||
<div id="demo-btns"></div> |
||||
<div id="sec-meta"> |
||||
<div class="meta-row"> |
||||
<label>Selected section</label> |
||||
<span class="meta-val green" id="meta-name">-</span> |
||||
</div> |
||||
<div class="meta-row"> |
||||
<label>Elements</label> |
||||
<span class="meta-val" id="meta-count">-</span> |
||||
</div> |
||||
<div class="meta-row"> |
||||
<label>Section flags</label> |
||||
<label class="flag-check"> |
||||
<input type="checkbox" id="flag-drawFront" onchange="setSectionFlag('drawFront',this.checked)" /> |
||||
drawFront |
||||
</label> |
||||
<label class="flag-check" style="margin-left:-8px"> |
||||
<input type="checkbox" id="flag-drawBack" onchange="setSectionFlag('drawBack',this.checked)" /> |
||||
drawBack |
||||
</label> |
||||
<label class="flag-check" style="margin-left:-8px"> |
||||
<input type="checkbox" id="flag-clearBuffer" onchange="setSectionFlag('clearBuffer',this.checked)" /> |
||||
clearBuffer |
||||
</label> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<div id="right"> |
||||
<div id="right-hdr"> |
||||
<span class="rh-title">sections</span> |
||||
</div> |
||||
<div id="sections-wrap"> |
||||
<div class="empty-state">No sections yet.<br />Use <b>Add section</b> to get started.</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
|
||||
<!-- confirm dialog --> |
||||
<div id="confirm-overlay"> |
||||
<div id="confirm-box"> |
||||
<div id="confirm-msg"></div> |
||||
<div id="confirm-btns"></div> |
||||
</div> |
||||
</div> |
||||
|
||||
<script src="./public/mono-display.js"></script> |
||||
</body> |
||||
|
||||
</html> |
||||
@ -0,0 +1,682 @@ |
||||
:root { |
||||
--bg: #111; |
||||
--bg-bar: #1a1a1a; |
||||
--bg-raised: #161616; |
||||
--bg-hover: #1b1b1b; |
||||
--bg-hover2: #222; |
||||
--bg-sunken: #141414; |
||||
--bg-accent: #141e14; |
||||
--bg-active: #1e2e1e; |
||||
--bg-deep: #0f0f0f; |
||||
--bg-input: #0a0a0a; |
||||
--bg-canvas: #000; |
||||
--bg-xhover: #1e1010; |
||||
--bg-overlay: rgba(0,0,0,.6); |
||||
--bd: #2a2a2a; |
||||
--bd-inner: #1e1e1e; |
||||
--bd-deep: #1c1c1c; |
||||
--bd-card: #222; |
||||
--bd-btn: #2d2d2d; |
||||
--bd-type: #252525; |
||||
--bd-active: #2b3d2b; |
||||
--bd-hover: #444; |
||||
--bd-strong: #333; |
||||
--tx: #ccc; |
||||
--tx-hover: #bbb; |
||||
--tx-sub: #888; |
||||
--tx-label: #777; |
||||
--tx-meta: #666; |
||||
--tx-dim: #555; |
||||
--tx-faint: #444; |
||||
--tx-ghost: #333; |
||||
--tx-file: #3a3a3a; |
||||
--tx-empty: #2e2e2e; |
||||
--ac: #33ff66; |
||||
--dirty: #886622 |
||||
} |
||||
|
||||
@media (prefers-color-scheme: light) { |
||||
:root:not([data-theme="dark"]) { |
||||
--bg: #f5f5f5; |
||||
--bg-bar: #e8e8e8; |
||||
--bg-raised: #efefef; |
||||
--bg-hover: #e5e5e5; |
||||
--bg-hover2: #e0e0e0; |
||||
--bg-sunken: #ebebeb; |
||||
--bg-accent: #e8f5e8; |
||||
--bg-active: #dff0df; |
||||
--bg-deep: #f0f0f0; |
||||
--bg-input: #fff; |
||||
--bg-canvas: #fff; |
||||
--bg-xhover: #ffe8e8; |
||||
--bg-overlay: rgba(0,0,0,.4); |
||||
--bd: #d0d0d0; |
||||
--bd-inner: #ddd; |
||||
--bd-deep: #e0e0e0; |
||||
--bd-card: #ddd; |
||||
--bd-btn: #ccc; |
||||
--bd-type: #d8d8d8; |
||||
--bd-active: #7dc87d; |
||||
--bd-hover: #bbb; |
||||
--bd-strong: #ccc; |
||||
--tx: #222; |
||||
--tx-hover: #333; |
||||
--tx-sub: #555; |
||||
--tx-label: #666; |
||||
--tx-meta: #777; |
||||
--tx-dim: #888; |
||||
--tx-faint: #999; |
||||
--tx-ghost: #aaa; |
||||
--tx-file: #aaa; |
||||
--tx-empty: #bbb; |
||||
--ac: #1a9940; |
||||
--dirty: #b37a00 |
||||
} |
||||
} |
||||
|
||||
[data-theme="light"] { |
||||
--bg: #f5f5f5; |
||||
--bg-bar: #e8e8e8; |
||||
--bg-raised: #efefef; |
||||
--bg-hover: #e5e5e5; |
||||
--bg-hover2: #e0e0e0; |
||||
--bg-sunken: #ebebeb; |
||||
--bg-accent: #e8f5e8; |
||||
--bg-active: #dff0df; |
||||
--bg-deep: #f0f0f0; |
||||
--bg-input: #fff; |
||||
--bg-canvas: #fff; |
||||
--bg-xhover: #ffe8e8; |
||||
--bg-overlay: rgba(0,0,0,.4); |
||||
--bd: #d0d0d0; |
||||
--bd-inner: #ddd; |
||||
--bd-deep: #e0e0e0; |
||||
--bd-card: #ddd; |
||||
--bd-btn: #ccc; |
||||
--bd-type: #d8d8d8; |
||||
--bd-active: #7dc87d; |
||||
--bd-hover: #bbb; |
||||
--bd-strong: #ccc; |
||||
--tx: #222; |
||||
--tx-hover: #333; |
||||
--tx-sub: #555; |
||||
--tx-label: #666; |
||||
--tx-meta: #777; |
||||
--tx-dim: #888; |
||||
--tx-faint: #999; |
||||
--tx-ghost: #aaa; |
||||
--tx-file: #aaa; |
||||
--tx-empty: #bbb; |
||||
--ac: #1a9940; |
||||
--dirty: #b37a00 |
||||
} |
||||
|
||||
*, |
||||
*::before, |
||||
*::after { |
||||
box-sizing: border-box; |
||||
margin: 0; |
||||
padding: 0 |
||||
} |
||||
|
||||
body { |
||||
background: var(--bg); |
||||
color: var(--tx); |
||||
font-family: monospace; |
||||
height: 100vh; |
||||
display: flex; |
||||
flex-direction: column; |
||||
overflow: hidden; |
||||
font-size: 12px |
||||
} |
||||
|
||||
#topbar { |
||||
height: 36px; |
||||
background: var(--bg-bar); |
||||
border-bottom: 1px solid var(--bd); |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 8px; |
||||
padding: 0 12px; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
#topbar .app-name { |
||||
color: var(--tx-dim); |
||||
font-size: 11px; |
||||
letter-spacing: .12em; |
||||
text-transform: uppercase; |
||||
margin-right: 4px |
||||
} |
||||
|
||||
.tb-btn { |
||||
background: var(--bg-raised); |
||||
color: var(--tx-sub); |
||||
border: 1px solid var(--bd-btn); |
||||
border-radius: 3px; |
||||
padding: 3px 10px; |
||||
font-family: monospace; |
||||
font-size: 11px; |
||||
cursor: pointer; |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 5px; |
||||
white-space: nowrap |
||||
} |
||||
|
||||
.tb-btn:hover { |
||||
background: var(--bg-hover2); |
||||
color: var(--tx-hover); |
||||
border-color: var(--bd-hover) |
||||
} |
||||
|
||||
.tb-btn svg { |
||||
width: 13px; |
||||
height: 13px; |
||||
stroke: currentColor; |
||||
fill: none; |
||||
stroke-width: 1.8; |
||||
stroke-linecap: round; |
||||
stroke-linejoin: round; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
#file-input { |
||||
display: none |
||||
} |
||||
|
||||
#filename { |
||||
color: var(--tx-file); |
||||
font-size: 11px; |
||||
margin-left: 2px; |
||||
max-width: 200px; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
white-space: nowrap |
||||
} |
||||
|
||||
.tb-sep { |
||||
width: 1px; |
||||
height: 18px; |
||||
background: var(--bd) |
||||
} |
||||
|
||||
.dirty { |
||||
color: var(--dirty) !important |
||||
} |
||||
|
||||
#main { |
||||
flex: 1; |
||||
display: flex; |
||||
min-height: 0 |
||||
} |
||||
|
||||
#left { |
||||
width: 48%; |
||||
border-right: 1px solid var(--bd-inner); |
||||
display: flex; |
||||
flex-direction: column; |
||||
padding: 14px; |
||||
gap: 10px; |
||||
overflow-y: auto; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
#display-box { |
||||
width: 100%; |
||||
aspect-ratio: 2/1; |
||||
background: var(--bg-canvas); |
||||
border: 1px solid var(--bd); |
||||
border-radius: 3px; |
||||
overflow: hidden |
||||
} |
||||
|
||||
#canvas_root { |
||||
width: 100%; |
||||
height: 100%; |
||||
display: block; |
||||
image-rendering: pixelated; |
||||
image-rendering: crisp-edges |
||||
} |
||||
|
||||
.pv-label { |
||||
font-size: 10px; |
||||
color: var(--tx-ghost); |
||||
letter-spacing: .1em; |
||||
text-transform: uppercase |
||||
} |
||||
|
||||
#demo-btns { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 5px; |
||||
margin-top: 6px |
||||
} |
||||
#demo-btns > div { |
||||
display: contents; |
||||
} |
||||
|
||||
canvas.pixel-editor { |
||||
display: block; |
||||
cursor: crosshair; |
||||
image-rendering: pixelated; |
||||
max-width: 100%; |
||||
border: 1px solid var(--bd-inner); |
||||
} |
||||
.anim-editor { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 4px; |
||||
} |
||||
.anim-frame-tabs { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 3px; |
||||
align-items: center; |
||||
} |
||||
.anim-frame-tab { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 2px; |
||||
padding: 2px 6px; |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
cursor: pointer; |
||||
font-size: 11px; |
||||
user-select: none; |
||||
} |
||||
.anim-frame-tab.active { |
||||
border-color: var(--accent, #EC0); |
||||
color: var(--accent, #EC0); |
||||
} |
||||
.x-btn-sm { |
||||
background: none; |
||||
border: none; |
||||
color: inherit; |
||||
cursor: pointer; |
||||
padding: 0 2px; |
||||
font-size: 13px; |
||||
line-height: 1; |
||||
opacity: 0.6; |
||||
} |
||||
.x-btn-sm:hover { opacity: 1; } |
||||
|
||||
#sec-meta { |
||||
background: var(--bg-sunken); |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
padding: 8px 10px; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 6px |
||||
} |
||||
|
||||
.meta-row { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 8px; |
||||
font-size: 11px |
||||
} |
||||
|
||||
.meta-row label { |
||||
color: var(--tx-faint); |
||||
min-width: 90px |
||||
} |
||||
|
||||
.meta-val { |
||||
color: var(--tx-meta) |
||||
} |
||||
|
||||
.green { |
||||
color: var(--ac) |
||||
} |
||||
|
||||
select.meta-val { |
||||
background: var(--bg-input); |
||||
color: var(--ac); |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
padding: 1px 4px; |
||||
} |
||||
|
||||
.flag-check { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 5px; |
||||
font-size: 11px; |
||||
color: var(--tx-dim); |
||||
cursor: pointer |
||||
} |
||||
|
||||
.flag-check input { |
||||
accent-color: var(--ac); |
||||
cursor: pointer |
||||
} |
||||
|
||||
#right { |
||||
flex: 1; |
||||
display: flex; |
||||
flex-direction: column; |
||||
min-height: 0; |
||||
overflow: hidden |
||||
} |
||||
|
||||
#right-hdr { |
||||
height: 36px; |
||||
background: var(--bg-sunken); |
||||
border-bottom: 1px solid var(--bd-inner); |
||||
display: flex; |
||||
align-items: center; |
||||
padding: 0 10px; |
||||
gap: 6px; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
#right-hdr .rh-title { |
||||
flex: 1; |
||||
color: var(--tx-faint); |
||||
font-size: 11px; |
||||
letter-spacing: .08em; |
||||
text-transform: uppercase |
||||
} |
||||
|
||||
#sections-wrap { |
||||
flex: 1; |
||||
overflow-y: auto; |
||||
padding: 8px |
||||
} |
||||
|
||||
.empty-state { |
||||
color: var(--tx-empty); |
||||
font-size: 11px; |
||||
padding: 28px 16px; |
||||
text-align: center; |
||||
line-height: 2 |
||||
} |
||||
|
||||
/* section card */ |
||||
.sec-card { |
||||
border: 1px solid var(--bd-card); |
||||
border-radius: 3px; |
||||
margin-bottom: 5px; |
||||
overflow: hidden |
||||
} |
||||
|
||||
.sec-card.active { |
||||
border-color: var(--bd-active) |
||||
} |
||||
|
||||
.sec-hdr { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 7px; |
||||
padding: 6px 8px; |
||||
cursor: pointer; |
||||
user-select: none; |
||||
background: var(--bg-raised); |
||||
position: relative |
||||
} |
||||
|
||||
.sec-hdr:hover { |
||||
background: var(--bg-hover) |
||||
} |
||||
|
||||
.sec-card.active .sec-hdr, |
||||
.sec-hdr.active { |
||||
background: var(--bg-active) |
||||
} |
||||
|
||||
.sec-arrow { |
||||
color: var(--tx-ghost); |
||||
font-size: 13px; |
||||
line-height: 1; |
||||
transition: transform .12s; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
.sec-card.open .sec-arrow { |
||||
transform: rotate(90deg) |
||||
} |
||||
|
||||
.sec-label { |
||||
flex: 1; |
||||
color: var(--tx-label); |
||||
font-size: 11px |
||||
} |
||||
|
||||
.sec-badge { |
||||
font-size: 10px; |
||||
color: var(--ac); |
||||
border: 1px solid var(--bd-active); |
||||
background: var(--bg-accent); |
||||
border-radius: 2px; |
||||
padding: 1px 6px; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
/* red × - section & element */ |
||||
.x-btn { |
||||
background: none; |
||||
border: none; |
||||
color: var(--bd); |
||||
cursor: pointer; |
||||
font-size: 15px; |
||||
line-height: 1; |
||||
padding: 2px 5px; |
||||
border-radius: 2px; |
||||
flex-shrink: 0; |
||||
transition: color .1s, background .1s |
||||
} |
||||
|
||||
.x-btn:hover { |
||||
color: #ff4444; |
||||
background: var(--bg-xhover) |
||||
} |
||||
|
||||
/* element */ |
||||
.el-list { |
||||
background: var(--bg-deep); |
||||
border-top: 1px solid var(--bd-deep); |
||||
padding: 6px |
||||
} |
||||
|
||||
.el-item { |
||||
border: 1px solid var(--bd-inner); |
||||
border-radius: 3px; |
||||
background: var(--bg-sunken); |
||||
margin-bottom: 4px; |
||||
overflow: hidden |
||||
} |
||||
|
||||
.el-item.active { |
||||
border-color: var(--ac) |
||||
} |
||||
|
||||
.el-item-hdr { |
||||
display: flex; |
||||
align-items: center; |
||||
gap: 6px; |
||||
padding: 5px 8px; |
||||
cursor: pointer |
||||
} |
||||
|
||||
.el-item-hdr:hover { |
||||
background: var(--bg-hover) |
||||
} |
||||
|
||||
.el-type { |
||||
font-size: 10px; |
||||
color: var(--tx-meta); |
||||
border: 1px solid var(--bd-type); |
||||
border-radius: 2px; |
||||
padding: 1px 6px; |
||||
flex-shrink: 0 |
||||
} |
||||
|
||||
.el-item.active .el-type { |
||||
color: var(--ac); |
||||
border-color: var(--bd-active) |
||||
} |
||||
|
||||
.el-name { |
||||
flex: 1; |
||||
color: var(--tx-dim); |
||||
font-size: 11px; |
||||
overflow: hidden; |
||||
text-overflow: ellipsis; |
||||
white-space: nowrap; |
||||
max-width: 120px |
||||
} |
||||
|
||||
.el-fields { |
||||
padding: 6px 8px 8px; |
||||
border-top: 1px solid var(--bg-bar); |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 5px |
||||
} |
||||
|
||||
.fields-grid { |
||||
display: grid; |
||||
grid-template-columns: 1fr 1fr; |
||||
gap: 4px 10px |
||||
} |
||||
|
||||
.field { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 2px |
||||
} |
||||
|
||||
.field.full { |
||||
grid-column: 1/-1 |
||||
} |
||||
|
||||
.field>label { |
||||
font-size: 10px; |
||||
color: var(--tx-faint) |
||||
} |
||||
|
||||
.field input[type=text], |
||||
.field input[type=number], |
||||
.field input[type=datetime-local], |
||||
.field select, |
||||
.field textarea { |
||||
background: var(--bg-input); |
||||
color: var(--tx-sub); |
||||
border: 1px solid var(--bd-card); |
||||
border-radius: 2px; |
||||
padding: 3px 6px; |
||||
font-family: monospace; |
||||
font-size: 11px; |
||||
width: 100%; |
||||
outline: none |
||||
} |
||||
|
||||
.field input:focus, |
||||
.field select:focus, |
||||
.field textarea:focus { |
||||
border-color: var(--ac); |
||||
color: var(--tx) |
||||
} |
||||
|
||||
.field textarea { |
||||
resize: vertical; |
||||
min-height: 44px; |
||||
line-height: 1.4 |
||||
} |
||||
|
||||
.field select option { |
||||
background: var(--bg-bar); |
||||
color: var(--tx-ghost) |
||||
} |
||||
|
||||
.flags-row { |
||||
display: flex; |
||||
flex-wrap: wrap; |
||||
gap: 10px; |
||||
padding: 2px 0 |
||||
} |
||||
|
||||
.add-el { |
||||
background: transparent; |
||||
color: var(--tx-ghost); |
||||
border: 1px dashed var(--bd-card); |
||||
border-radius: 3px; |
||||
padding: 5px 8px; |
||||
font-family: monospace; |
||||
font-size: 11px; |
||||
cursor: pointer; |
||||
width: 100%; |
||||
text-align: center; |
||||
margin-top: 2px |
||||
} |
||||
|
||||
.add-el:hover { |
||||
color: var(--tx-sub); |
||||
border-color: var(--bd-hover) |
||||
} |
||||
|
||||
/* confirm dialog */ |
||||
#confirm-overlay { |
||||
display: none; |
||||
position: fixed; |
||||
inset: 0; |
||||
background: var(--bg-overlay); |
||||
z-index: 100; |
||||
align-items: center; |
||||
justify-content: center |
||||
} |
||||
|
||||
#confirm-overlay.show { |
||||
display: flex |
||||
} |
||||
|
||||
#confirm-box { |
||||
background: var(--bg-bar); |
||||
border: 1px solid var(--bd-strong); |
||||
border-radius: 4px; |
||||
padding: 20px 24px; |
||||
max-width: 340px; |
||||
width: 90%; |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 14px |
||||
} |
||||
|
||||
#confirm-msg { |
||||
color: var(--tx-ghost); |
||||
font-size: 12px; |
||||
line-height: 1.6 |
||||
} |
||||
|
||||
#confirm-btns { |
||||
display: flex; |
||||
gap: 8px; |
||||
justify-content: flex-end |
||||
} |
||||
|
||||
.cb { |
||||
background: var(--bg-raised); |
||||
color: var(--tx-sub); |
||||
border: 1px solid var(--bd-btn); |
||||
border-radius: 3px; |
||||
padding: 4px 14px; |
||||
font-family: monospace; |
||||
font-size: 11px; |
||||
cursor: pointer |
||||
} |
||||
|
||||
.cb:hover { |
||||
background: var(--bg-hover2); |
||||
color: var(--tx-hover) |
||||
} |
||||
|
||||
.cb.primary { |
||||
border-color: var(--bd-active); |
||||
color: var(--ac) |
||||
} |
||||
|
||||
.cb.primary:hover { |
||||
background: var(--bg-active) |
||||
} |
||||
|
After Width: | Height: | Size: 1.1 KiB |
@ -0,0 +1,43 @@ |
||||
<?php |
||||
|
||||
namespace monoformat { |
||||
|
||||
function isBitSet(int $value, int $bit) { |
||||
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) { |
||||
return false; |
||||
} |
||||
$mask = 1 << $bit; |
||||
return ($value & $mask) == $mask; |
||||
} |
||||
|
||||
function setBit(int &$value, int $bit) { |
||||
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) { |
||||
return; |
||||
} |
||||
$mask = 1 << $bit; |
||||
$value |= $mask; |
||||
} |
||||
|
||||
function unsetBit(int &$value, int $bit) { |
||||
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) { |
||||
return; |
||||
} |
||||
$mask = 1 << $bit; |
||||
$value &= ~$mask; |
||||
} |
||||
|
||||
function maybeSetBit(int &$value, int $bit, int $set) { |
||||
if ($bit < 0 || $bit >= (PHP_INT_SIZE * 8)) { |
||||
return; |
||||
} |
||||
$mask = 1 << $bit; |
||||
if ($set) { |
||||
$value |= $mask; |
||||
} else { |
||||
$value &= ~$mask; |
||||
} |
||||
} |
||||
|
||||
} // namespace monoformat |
||||
|
||||
?> |
||||
@ -0,0 +1,213 @@ |
||||
<?php |
||||
|
||||
namespace monoformat { |
||||
|
||||
include_once("monoformat_bithelpers.php"); |
||||
|
||||
trait FlagStruct { |
||||
public static function fromJSON(array $json) { |
||||
$myClass = static::class; |
||||
$reflect = new \ReflectionClass($myClass); |
||||
$properties = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC); |
||||
$result = new $myClass(); |
||||
foreach ($properties as $property) { |
||||
$name = $property->getName(); |
||||
if (array_key_exists($name, $json)) { |
||||
$result->$name = (bool) $json[$name]; |
||||
} |
||||
} |
||||
return $result; |
||||
} |
||||
|
||||
public function toJSON(): array { |
||||
$result = []; |
||||
$myClass = static::class; |
||||
$reflect = new \ReflectionClass($myClass); |
||||
$properties = $reflect->getProperties(\ReflectionProperty::IS_PUBLIC); |
||||
foreach ($properties as $property) { |
||||
$name = $property->getName(); |
||||
$result[$name] = $this->$name; |
||||
} |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
enum SectionType : int { |
||||
case AlwaysDrawn = 1; |
||||
case TimeBasedDrawn = 2; |
||||
case MultiTimeBasedDrawn = 3; |
||||
case ExpiryDate = 31; |
||||
case CustomFont = 32; |
||||
|
||||
public static function fromName(string $name): SectionType { |
||||
foreach (self::cases() as $case) { |
||||
if ($case->name === $name) { |
||||
return $case; |
||||
} |
||||
} |
||||
throw new \ValueError("$name is not a valid backing value for enum " . self::class); |
||||
} |
||||
} |
||||
|
||||
enum ElementType : int { |
||||
case Image = 1; |
||||
case Animation = 2; |
||||
case HScrollImage = 3; |
||||
case VScrollImage = 4; |
||||
case Line = 5; |
||||
case Box = 6; |
||||
case ClippedText = 16; |
||||
case HScrollText = 17; |
||||
//case VScrollText = 18; |
||||
case CurrentTime = 32; |
||||
|
||||
public static function fromName(string $name): SectionType { |
||||
foreach (self::cases() as $case) { |
||||
if ($case->name === $name) { |
||||
return $case; |
||||
} |
||||
} |
||||
throw new \ValueError("$name is not a valid backing value for enum " . self::class); |
||||
} |
||||
} |
||||
|
||||
class ScrollFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $endless = false; |
||||
public bool $invertDirection = false; |
||||
public bool $padBefore = false; |
||||
public bool $padAfter = false; |
||||
|
||||
public static function fromBitField(int $bitfield): ScrollFlags { |
||||
$result = new ScrollFlags(); |
||||
$result->endless = isBitSet($bitfield, 0); |
||||
$result->invertDirection = isBitSet($bitfield, 1); |
||||
$result->padBefore = isBitSet($bitfield, 2); |
||||
$result->padAfter = isBitSet($bitfield, 3); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->endless); |
||||
maybeSetBit($result, 1, $this->invertDirection); |
||||
maybeSetBit($result, 2, $this->padBefore); |
||||
maybeSetBit($result, 3, $this->padAfter); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
enum LineStyle : int { |
||||
case Solid = 0; |
||||
|
||||
public static function fromName(string $name): SectionType { |
||||
foreach (self::cases() as $case) { |
||||
if ($case->name === $name) { |
||||
return $case; |
||||
} |
||||
} |
||||
throw new \ValueError("$name is not a valid backing value for enum " . self::class); |
||||
} |
||||
} |
||||
|
||||
class LineFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $dark = false; |
||||
|
||||
public static function fromBitField(int $bitfield): LineFlags { |
||||
$result = new LineFlags(); |
||||
$result->dark = isBitSet($bitfield, 0); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->dark); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
enum FillPatternStyle : int { |
||||
case Solid = 0; |
||||
|
||||
public static function fromName(string $name): SectionType { |
||||
foreach (self::cases() as $case) { |
||||
if ($case->name === $name) { |
||||
return $case; |
||||
} |
||||
} |
||||
throw new \ValueError("$name is not a valid backing value for enum " . self::class); |
||||
} |
||||
} |
||||
|
||||
class FillFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $dark = false; |
||||
|
||||
public static function fromBitField(int $bitfield): FillFlags { |
||||
$result = new FillFlags(); |
||||
$result->dark = isBitSet($bitfield, 0); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->dark); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class TextFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $dark = false; |
||||
public bool $autoWordWrap = false; |
||||
|
||||
public static function fromBitField(int $bitfield): TextFlags { |
||||
$result = new TextFlags(); |
||||
$result->dark = isBitSet($bitfield, 0); |
||||
$result->autoWordWrap = isBitSet($bitfield, 1); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->dark); |
||||
maybeSetBit($result, 1, $this->autoWordWrap); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class TimeDisplayFlags { |
||||
use FlagStruct; |
||||
|
||||
public bool $use12h = false; |
||||
public bool $showHours = false; |
||||
public bool $showMinutes = false; |
||||
public bool $showSeconds = false; |
||||
|
||||
public static function fromBitField(int $bitfield): TimeDisplayFlags { |
||||
$result = new TimeDisplayFlags(); |
||||
$result->use12h = isBitSet($bitfield, 0); |
||||
$result->showHours = isBitSet($bitfield, 1); |
||||
$result->showMinutes = isBitSet($bitfield, 2); |
||||
$result->showSeconds = isBitSet($bitfield, 3); |
||||
return $result; |
||||
} |
||||
|
||||
public function toBitField(): int { |
||||
$result = 0; |
||||
maybeSetBit($result, 0, $this->use12h); |
||||
maybeSetBit($result, 1, $this->showHours); |
||||
maybeSetBit($result, 2, $this->showMinutes); |
||||
maybeSetBit($result, 3, $this->showSeconds); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
} // namespace monoformat |
||||
|
||||
?> |
||||
@ -0,0 +1,181 @@ |
||||
<?php |
||||
|
||||
include_once("monoformat_schema.php"); |
||||
include_once("monoformat_structured.php"); |
||||
|
||||
function replaceTalkInfo($str, $talks) { |
||||
return preg_replace_callback("/\\$\\{([^}]*)\\}/", function ($matches) use ($talks) { |
||||
if (str_contains($matches[1], ".")) { |
||||
[$id, $what] = explode(".", $matches[1], 2); |
||||
$id = (int) $id; |
||||
if ($id < 0 || $id >= count($talks)) { |
||||
return ""; |
||||
} |
||||
$talk = $talks[$id]; |
||||
if (array_key_exists($what, $talk)) { |
||||
return $talk[$what]; |
||||
} else { |
||||
return ""; |
||||
} |
||||
} else { |
||||
return ""; |
||||
} |
||||
}, $str); |
||||
} |
||||
|
||||
$roomMapping = [ |
||||
"1020ba76eb04" => "a873f07f-4ab2-57ea-9183-99b187e8b0cf", // Medientheater |
||||
"001122334466" => "0273ef15-d23a-5413-9c74-3edc092b6ee8", // Kubus |
||||
"001122334477" => "80d66dbe-d978-57b5-86e2-b3d480136b9a", // Vortragssaal |
||||
]; |
||||
|
||||
if (!isset($_GET["mac"]) or !array_key_exists($_GET["mac"], $roomMapping)) { |
||||
/* We didn't find this device in the mapping, so let's just |
||||
* send a default file to the user. |
||||
*/ |
||||
Header("Content-Type: application/octet-stream"); |
||||
readfile(dirname(__FILE__) . "/noroom.bin"); |
||||
exit(0); |
||||
} |
||||
|
||||
$roomGUID = strtolower($roomMapping[$_GET["mac"]]); |
||||
|
||||
$pretalx = json_decode(file_get_contents("https://cfp.gulas.ch/gpn24/schedule/export/schedule.json"), true); |
||||
|
||||
$roomName = null; |
||||
foreach ($pretalx["schedule"]["conference"]["rooms"] as $room) { |
||||
$thisRoomGUID = strtolower($room["guid"]); |
||||
if ($thisRoomGUID === $roomGUID) { |
||||
$roomName = $room["name"]; |
||||
break; |
||||
} |
||||
} |
||||
if ($roomName === null) { |
||||
die("Room not found!"); |
||||
} |
||||
|
||||
$talks = []; |
||||
$now = new DateTimeImmutable("now"); |
||||
|
||||
foreach ($pretalx["schedule"]["conference"]["days"] as $day) { |
||||
if (!array_key_exists($roomName, $day["rooms"])) { |
||||
continue; |
||||
} |
||||
$roomTalks = $day["rooms"][$roomName]; |
||||
foreach ($roomTalks as $t) { |
||||
[$h, $m] = explode(":", $t["duration"]); |
||||
$duration = $h * 3600 + $m * 60; |
||||
$duration = DateInterval::createFromDateString("$duration sec"); |
||||
$speakers = []; |
||||
foreach ($t["persons"] as $person) { |
||||
array_push($speakers, $person["name"]); |
||||
} |
||||
$talkBegin = new DateTimeImmutable($t["date"]); |
||||
$talkEnd = $talkBegin->add($duration); |
||||
if ($talkEnd < $now) { |
||||
continue; |
||||
} |
||||
$talk = [ |
||||
"Time" => $talkBegin->format("H:i"), |
||||
"Duration" => $duration->format("H:i"), |
||||
"Persons" => implode(", ", $speakers), |
||||
"Title" => $t["title"], |
||||
"_start" => $talkBegin, |
||||
"_end" => $talkEnd, |
||||
]; |
||||
array_push($talks, $talk); |
||||
} |
||||
} |
||||
|
||||
$template = file_get_contents(dirname(__FILE__) . "/template.bin"); |
||||
$template = monoformat\parseFile($template); |
||||
|
||||
if (count($template->sections) != 1 or $template->sections[0]->sectionType() != monoformat\SectionType::AlwaysDrawn) { |
||||
die("Invalid section in template file"); |
||||
} |
||||
|
||||
$elements = []; |
||||
for ($i = 0; $i < $template->sections[0]->elementCount(); ++$i) { |
||||
array_push($elements, $template->sections[0]->elementAt($i)); |
||||
} |
||||
|
||||
$end_template = file_get_contents(dirname(__FILE__) . "/endscreen.bin"); |
||||
$end_template = monoformat\parseFile($end_template); |
||||
|
||||
if (count($end_template->sections) != 1 or $end_template->sections[0]->sectionType() != monoformat\SectionType::AlwaysDrawn) { |
||||
die("Invalid section in end screen template file"); |
||||
} |
||||
|
||||
$end_elements = []; |
||||
for ($i = 0; $i < $end_template->sections[0]->elementCount(); ++$i) { |
||||
array_push($end_elements, $end_template->sections[0]->elementAt($i)); |
||||
} |
||||
|
||||
$n = 42; |
||||
$last = false; |
||||
if ($n > count($talks)) { |
||||
$n = count($talks); |
||||
$last = true; |
||||
} |
||||
|
||||
$lastEnd = 0; |
||||
$outputFile = new monoformat\File(); |
||||
if (!$last) { |
||||
$section = new monoformat\ExpiryDateSection(); |
||||
$section->setExpiryDate($talks[$n - 1]["_end"]->getTimestamp()); |
||||
array_push($outputFile->sections, $section); |
||||
} |
||||
for ($i = 0; $i < $n; ++$i) { |
||||
$subTalks = array_slice($talks, $i); |
||||
$end = $subTalks[0]["_end"]->getTimestamp(); |
||||
$section = new monoformat\TimeBasedDrawnSection(); |
||||
$section->setDrawOnFront(true); |
||||
$section->setDrawOnBack(true); |
||||
$section->setClearBeforeDrawing(true); |
||||
$section->setStartTimestamp($lastEnd); |
||||
$section->setEndTimestamp($end); |
||||
foreach ($elements as $element) { |
||||
if ($element->elementType() == monoformat\ElementType::ClippedText) { |
||||
$x = $element->x(); |
||||
$y = $element->y(); |
||||
$width = $element->width(); |
||||
$height = $element->height(); |
||||
$textFlags = $element->textFlags(); |
||||
$fontIndex = $element->fontIndex(); |
||||
$text = replaceTalkInfo($element->text(), $subTalks); |
||||
$element = new monoformat\ClippedTextElement($x, $y, $width, $height, $textFlags, $fontIndex, $text); |
||||
} else if ($element->elementType() == monoformat\ElementType::HScrollText) { |
||||
$x = $element->x(); |
||||
$y = $element->y(); |
||||
$width = $element->width(); |
||||
$height = $element->height(); |
||||
$textFlags = $element->textFlags(); |
||||
$flags = $element->flags(); |
||||
$scrollSpeed = $element->scrollSpeed(); |
||||
$fontIndex = $element->fontIndex(); |
||||
$text = replaceTalkInfo($element->text(), $subTalks) . " "; |
||||
$element = new monoformat\HScrollTextElement($x, $y, $width, $height, $textFlags, $flags, $scrollSpeed, $fontIndex, $text); |
||||
} |
||||
$section->appendElement($element); |
||||
} |
||||
array_push($outputFile->sections, $section); |
||||
$lastEnd = $end; |
||||
} |
||||
if ($last) { |
||||
$section = new monoformat\TimeBasedDrawnSection(); |
||||
$section->setDrawOnFront(true); |
||||
$section->setDrawOnBack(true); |
||||
$section->setClearBeforeDrawing(true); |
||||
$section->setStartTimestamp($lastEnd); |
||||
// Set this far enough in the future that it doesn't matter |
||||
$section->setEndTimestamp($lastEnd + 86400 * 300); |
||||
foreach ($end_elements as $element) { |
||||
$section->appendElement($element); |
||||
} |
||||
array_push($outputFile->sections, $section); |
||||
} |
||||
|
||||
Header("Content-Type: application/octet-stream"); |
||||
print(monoformat\serializeFile($outputFile)); |
||||
|
||||
?> |
||||
@ -0,0 +1,83 @@ |
||||
|
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head> |
||||
<meta charset="UTF-8" /> |
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||
<title>Busanzeiger Paint</title> |
||||
<link rel="stylesheet" href="styles.css"> |
||||
<script src="https://cdn.jsdelivr.net/npm/omggif@1.0.10/omggif.min.js"></script> |
||||
<script src="alphabet-extended.js"></script> |
||||
<script src="binfmt.js"></script> |
||||
</head> |
||||
<body> |
||||
<h1>Busanzeiger Paint</h1> |
||||
|
||||
<div id="modeToggle"> |
||||
<label><input type="radio" name="mode" value="image" checked> Image Mode</label> |
||||
<label><input type="radio" name="mode" value="text"> Text Mode</label> |
||||
<label style="margin-left: 20px;"><input type="checkbox" id="animationCreation"> Animation Creation</label> |
||||
<label style="margin-left: 20px;"> |
||||
Duration (seconds): <input type="number" id="animationDuration" min="0.1" max="60" step="0.1" value="3.0" style="width: 60px;"> |
||||
<button id="revertDuration" title="Unlock and recalculate from frame count">↻</button> |
||||
</label> |
||||
</div> |
||||
|
||||
<div id="dropzone">Drag and drop image(s) here or click to upload</div> |
||||
|
||||
<div id="controls"> |
||||
<input type="file" id="upload" accept="image/*" style="display:none;" /> |
||||
<button id="invert">Invert</button> |
||||
<button id="dither">Dither: OFF</button> |
||||
<button id="export">Prepare Export</button> |
||||
<button id="downloadBinary">Download Binary</button> |
||||
<button id="draw">Draw</button> |
||||
<button id="erase">Erase</button> |
||||
<label> |
||||
Brush: |
||||
<select id="brushSize"> |
||||
<option value="1" selected>1px</option> |
||||
<option value="2">2px</option> |
||||
<option value="4">4px</option> |
||||
<option value="6">6px</option> |
||||
</select> |
||||
</label> |
||||
<button id="reset">Reset</button> |
||||
</div> |
||||
|
||||
<div id="frameControls" style="display: none; margin-top: 10px; display: flex; gap: 10px; align-items: center; justify-content: center;"> |
||||
<button id="prevFrame">← Prev Frame</button> |
||||
<button id="playStop">▶ Play</button> |
||||
<span id="frameCounter"></span> |
||||
<button id="nextFrame">Next Frame →</button> |
||||
<button id="addEmptyFrame" style="display: none;">+ Empty Frame</button> |
||||
<button id="duplicateFrame" style="display: none;">+ Duplicate Frame</button> |
||||
<button id="deleteFrame" style="display: none;">🗑 Delete Frame</button> |
||||
</div> |
||||
|
||||
<div id="textInputArea"> |
||||
<label>Scrolling Text:<br> |
||||
<textarea id="scrollText" rows="4" cols="40"></textarea> |
||||
</label> |
||||
<label>Speed: <input type="number" id="scrollSpeed" min="0" max="63" value="4"></label> |
||||
<label><input type="checkbox" id="scrollRight"> Scroll Right</label> |
||||
<label><input type="checkbox" id="scrollWrap" checked> Wrap Around</label> |
||||
</div> |
||||
|
||||
<div id="matrix"></div> |
||||
|
||||
<form action="upload.php" method="post"> |
||||
<textarea id="array_output" name="array_output" rows="4" cols="50"></textarea> |
||||
<textarea id="scrolls_output" name="scrolls" rows="4" cols="50" style="display:none;"></textarea> |
||||
<br> |
||||
Filename: <input type="text" id="fileName" name="fileName"> |
||||
<br> |
||||
Passwort: <input type="password" id="password" name="password"> |
||||
<button type="submit">Speichern</button> |
||||
</form> |
||||
|
||||
<script src="app.js"></script> |
||||
|
||||
</body> |
||||
</html> |
||||
|
||||
@ -0,0 +1,215 @@ |
||||
<?php |
||||
session_start(); |
||||
|
||||
define('PASSWORD', 'grund_cttue_gesetz'); |
||||
define('FILES_DIR', __DIR__ . '/avj2305'); |
||||
define('ACTIVE_FILE', __DIR__ . '/active.txt'); |
||||
|
||||
// ============================================================================= |
||||
// HELPERS |
||||
// ============================================================================= |
||||
|
||||
function redirect(string $url): never { |
||||
header('Location: ' . $url); |
||||
exit; |
||||
} |
||||
|
||||
function require_auth(): void { |
||||
if (empty($_SESSION['auth'])) redirect('?login'); |
||||
} |
||||
|
||||
function get_active(): string { |
||||
if (!file_exists(ACTIVE_FILE)) return ''; |
||||
$v = trim(file_get_contents(ACTIVE_FILE)); |
||||
return $v !== '' ? $v : ''; |
||||
} |
||||
|
||||
function set_active(string $filename): void { |
||||
file_put_contents(ACTIVE_FILE, $filename); |
||||
} |
||||
|
||||
function get_bin_files(): array { |
||||
$files = glob(FILES_DIR . '/*.bin'); |
||||
return $files ? array_map('basename', $files) : []; |
||||
} |
||||
|
||||
function safe_filename(string $name): bool { |
||||
return $name === basename($name) |
||||
&& str_ends_with($name, '.bin') |
||||
&& !str_contains($name, "\0"); |
||||
} |
||||
|
||||
function has_png(string $bin_basename): bool { |
||||
$png = substr($bin_basename, 0, -4) . '.png'; |
||||
return file_exists(FILES_DIR . '/' . $png); |
||||
} |
||||
|
||||
function png_path(string $bin_basename): string { |
||||
return FILES_DIR . '/' . substr($bin_basename, 0, -4) . '.png'; |
||||
} |
||||
|
||||
// ============================================================================= |
||||
// ACTIONS (POST handlers - redirect, never render) |
||||
// ============================================================================= |
||||
|
||||
function action_login(): never { |
||||
if (hash_equals(PASSWORD, $_POST['password'] ?? '')) { |
||||
$_SESSION['auth'] = true; |
||||
redirect('?edit'); |
||||
} |
||||
redirect('?login&err=1'); |
||||
} |
||||
|
||||
function action_set_file(): never { |
||||
require_auth(); |
||||
$f = $_POST['file'] ?? ''; |
||||
if (safe_filename($f) && file_exists(FILES_DIR . '/' . $f)) { |
||||
set_active($f); |
||||
} |
||||
redirect('?edit'); |
||||
} |
||||
|
||||
// ============================================================================= |
||||
// RENDERERS (GET handlers - output HTML, never redirect) |
||||
// ============================================================================= |
||||
|
||||
function render_login(): never { |
||||
$err = !empty($_GET['err']); ?> |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head><meta charset="utf-8"><title>Login</title> |
||||
<style> |
||||
body{font:14px monospace;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#111;color:#eee} |
||||
form{display:flex;flex-direction:column;gap:8px;width:220px} |
||||
input[type=password]{padding:6px;background:#222;border:1px solid #555;color:#eee} |
||||
button{padding:6px;background:#444;border:none;color:#eee;cursor:pointer} |
||||
button:hover{background:#555} |
||||
.err{color:#f66;font-size:12px} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<form method="post" action="?login"> |
||||
<label>Password</label> |
||||
<input type="password" name="password" autofocus> |
||||
<button type="submit">Login</button> |
||||
<?php if ($err): ?><span class="err">Wrong password.</span><?php endif; ?> |
||||
</form> |
||||
</body></html> |
||||
<?php exit; } |
||||
|
||||
function render_edit(): never { |
||||
require_auth(); |
||||
$files = get_bin_files(); |
||||
$active = get_active(); ?> |
||||
<!DOCTYPE html> |
||||
<html lang="en"> |
||||
<head><meta charset="utf-8"><title>Select File</title> |
||||
<style> |
||||
body{font:14px monospace;background:#111;color:#eee;padding:24px;margin:0} |
||||
h2{margin:0 0 16px} |
||||
ul{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px} |
||||
li form{margin:0} |
||||
.row{display:flex;align-items:center;gap:10px} |
||||
button{padding:6px 12px;background:#333;border:1px solid #555;color:#eee;cursor:pointer;flex:1;text-align:left} |
||||
button:hover{background:#444} |
||||
button.active{border-color:#6af;color:#6af} |
||||
.thumb{width:480px;height:270px;object-fit:cover;border:1px solid #444;background:#222;flex-shrink:0} |
||||
.nothumb{width:64px;height:64px;flex-shrink:0} |
||||
.none{color:#888} |
||||
</style> |
||||
</head> |
||||
<body> |
||||
<h2>Live file selector</h2> |
||||
<p>Active: <strong><?= $active !== '' ? htmlspecialchars($active) : '<span class="none">none</span>' ?></strong></p>
|
||||
<?php if ($files): ?> |
||||
<ul> |
||||
<?php foreach ($files as $f): ?> |
||||
<li> |
||||
<form method="post" action="?edit"> |
||||
<div class="row"> |
||||
<?php if (has_png($f)): ?> |
||||
<img class="thumb" src="?img=<?= urlencode($f) ?>" alt="">
|
||||
<?php else: ?> |
||||
<div class="nothumb"></div> |
||||
<?php endif; ?> |
||||
<input type="hidden" name="file" value="<?= htmlspecialchars($f) ?>">
|
||||
<button type="submit"<?= $f === $active ? ' class="active"' : '' ?>><?= htmlspecialchars($f) ?></button>
|
||||
</div> |
||||
</form> |
||||
</li> |
||||
<?php endforeach; ?> |
||||
</ul> |
||||
<?php else: ?> |
||||
<p class="none">No .bin files found in avj2305/</p> |
||||
<?php endif; ?> |
||||
</body></html> |
||||
<?php exit; } |
||||
|
||||
// ============================================================================= |
||||
// FILE SERVER (default route) |
||||
// ============================================================================= |
||||
|
||||
function serve_png(): never { |
||||
require_auth(); |
||||
$f = $_GET['img'] ?? ''; |
||||
if (!safe_filename($f)) { |
||||
http_response_code(400); exit('Invalid filename.'); |
||||
} |
||||
$path = png_path($f); |
||||
if (!file_exists($path)) { |
||||
http_response_code(404); exit('No preview.'); |
||||
} |
||||
header('Content-Type: image/png'); |
||||
header('Content-Length: ' . filesize($path)); |
||||
readfile($path); |
||||
exit; |
||||
} |
||||
|
||||
function serve_active_file(): never { |
||||
$active = get_active(); |
||||
|
||||
if ($active === '') { |
||||
http_response_code(404); exit('No active file set.'); |
||||
} |
||||
if (!safe_filename($active)) { |
||||
http_response_code(500); exit('Invalid filename.'); |
||||
} |
||||
|
||||
$path = FILES_DIR . '/' . $active; |
||||
|
||||
if (!file_exists($path)) { |
||||
http_response_code(404); exit('File not found.'); |
||||
} |
||||
|
||||
header('Content-Type: application/octet-stream'); |
||||
header('Content-Disposition: inline; filename="' . addslashes($active) . '"'); |
||||
header('Content-Length: ' . filesize($path)); |
||||
readfile($path); |
||||
exit; |
||||
} |
||||
|
||||
|
||||
// ============================================================================= |
||||
// ROUTES |
||||
// ============================================================================= |
||||
// |
||||
// GET /live.php -> serve active .bin file |
||||
// GET /live.php?login -> login page |
||||
// POST /live.php?login -> process login |
||||
// GET /live.php?edit -> file picker [auth required] |
||||
// POST /live.php?edit -> set active file [auth required] |
||||
// GET /live.php?img=x.bin -> serve x.png preview [auth required] |
||||
// |
||||
// ============================================================================= |
||||
|
||||
$method = $_SERVER['REQUEST_METHOD']; |
||||
$route = array_key_first($_GET) ?? ''; |
||||
|
||||
match (true) { |
||||
$method === 'POST' && $route === 'login' => action_login(), |
||||
$method === 'POST' && $route === 'edit' => action_set_file(), |
||||
$method === 'GET' && $route === 'login' => render_login(), |
||||
$method === 'GET' && $route === 'edit' => render_edit(), |
||||
$method === 'GET' && $route === 'img' => serve_png(), |
||||
default => serve_active_file(), |
||||
}; |
||||
@ -0,0 +1,203 @@ |
||||
<?php |
||||
|
||||
namespace monoformat { |
||||
|
||||
enum SectionType : int { |
||||
case AlwaysDrawn = 1; |
||||
case TimeBasedDrawn = 2; |
||||
case CustomFont = 32; |
||||
} |
||||
|
||||
enum ElementType : int { |
||||
case Image = 1; |
||||
case Animation = 2; |
||||
case HScrollImage = 3; |
||||
case VScrollImage = 4; |
||||
case Line = 5; |
||||
case ClippedText = 16; |
||||
case HScrollText = 17; |
||||
case CurrentTime = 32; |
||||
} |
||||
|
||||
enum LineStyle : int { |
||||
case Solid = 0; |
||||
} |
||||
|
||||
class Element { |
||||
public function serialize() { |
||||
return ""; |
||||
} |
||||
} |
||||
|
||||
class HScrollTextElement extends Element { |
||||
public $x = 0; |
||||
public $y = 0; |
||||
public $width = 0; |
||||
public $height = 0; |
||||
public $flags = 0; |
||||
public $scrollSpeed = 0; |
||||
public $fontIndex = 0; |
||||
public $text = ""; |
||||
|
||||
public function serialize() { |
||||
$len = strlen($this->text); |
||||
$result = pack("vvvvvCCvv", |
||||
17, |
||||
$this->x, |
||||
$this->y, |
||||
$this->width, |
||||
$this->height, |
||||
$this->flags, |
||||
$this->scrollSpeed, |
||||
$this->fontIndex, |
||||
$len); |
||||
$result .= (string) $this->text; |
||||
if ($len % 4 != 0) { |
||||
$n = 4 - ($len % 4); |
||||
for ($i = 0; $i < $n; ++$i) { |
||||
$result .= chr(0); |
||||
} |
||||
} |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class CurrentTimeElement extends Element { |
||||
public $x = 0; |
||||
public $y = 0; |
||||
public $width = 0; |
||||
public $height = 0; |
||||
public $fontIndex = 0; |
||||
public $utcOffset = 0; |
||||
public $flags = 0; |
||||
|
||||
public function serialize() { |
||||
$result = pack("vvvvvvvv", |
||||
32, |
||||
$this->x, |
||||
$this->y, |
||||
$this->width, |
||||
$this->height, |
||||
$this->fontIndex, |
||||
$this->utcOffset, |
||||
$this->flags); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class Section { |
||||
public function serialize() { |
||||
return ""; |
||||
} |
||||
} |
||||
|
||||
class AlwaysDrawnSection extends Section { |
||||
public $drawOnFront = false; |
||||
public $drawOnBack = false; |
||||
public $clearBeforeDrawing = false; |
||||
public $elements = []; |
||||
|
||||
public function serialize() { |
||||
$flags = 0; |
||||
if ($this->drawOnFront) { |
||||
$flags |= 0x01; |
||||
} |
||||
if ($this->drawOnBack) { |
||||
$flags |= 0x02; |
||||
} |
||||
if ($this->clearBeforeDrawing) { |
||||
$flags |= 0x04; |
||||
} |
||||
$inner = pack("vv", $flags, count($this->elements)); |
||||
foreach ($this->elements as $element) { |
||||
$inner .= $element->serialize(); |
||||
} |
||||
$len = strlen($inner) + 4; |
||||
$len = substr(pack("V", $len), 0, 3); |
||||
return pack("C", 1) . $len . $inner; |
||||
} |
||||
} |
||||
|
||||
class File { |
||||
public $sections = []; |
||||
|
||||
public function serialize() { |
||||
$nSections = count($this->sections); |
||||
$result = "\xAF\x7E\x2B\x63"; |
||||
$result .= pack("Vvv", 1, $nSections, 0); |
||||
foreach ($this->sections as $section) { |
||||
$result .= $section->serialize(); |
||||
} |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
} // namespace monoformat |
||||
|
||||
namespace { |
||||
|
||||
$roomName= "BOOL"; |
||||
|
||||
$data = json_decode(file_get_contents("https://cfp.cttue.de/tdf5/schedule/export/schedule.json"), true); |
||||
$talks = []; |
||||
$now = new DateTimeImmutable("now"); |
||||
foreach ($data["schedule"]["conference"]["days"] as $day) { |
||||
foreach ($day["rooms"][$roomName] as $t) { |
||||
[$h, $m] = explode(":", $t["duration"]); |
||||
$duration = $h * 3600 + $m * 60; |
||||
$duration = DateInterval::createFromDateString("$duration sec"); |
||||
$talk = [ |
||||
"date" => new DateTimeImmutable($t["date"]), |
||||
"duration" => $duration, |
||||
"title" => $t["title"], |
||||
]; |
||||
$talkEnd = $talk["date"]->add($talk["duration"]); |
||||
if ($talkEnd < $now) { |
||||
continue; |
||||
} |
||||
if ($talk["date"] > $now && count($talks) > 2) { |
||||
break; |
||||
} |
||||
array_push($talks, $talk); |
||||
} |
||||
} |
||||
|
||||
$elements = []; |
||||
|
||||
function putText($x, $y, $width, $height, $text) { |
||||
global $elements; |
||||
|
||||
$e = new monoformat\HScrollTextElement(); |
||||
$e->x = $x; |
||||
$e->y = $y; |
||||
$e->width = $width; |
||||
$e->height = $height; |
||||
$e->flags = 0; |
||||
$e->scrollSpeed = 15; |
||||
$e->fontIndex = 0; |
||||
$e->text = $text; |
||||
array_push($elements, $e); |
||||
} |
||||
|
||||
$i = 0; |
||||
foreach ($talks as $talk) { |
||||
putText(10, $i * 20, 25, 20, $talk["date"]->format("H:i")); |
||||
putText(35, $i * 20, 85, 20, $talk["title"]); |
||||
++$i; |
||||
} |
||||
|
||||
$section = new monoformat\AlwaysDrawnSection(); |
||||
$section->drawOnFront = true; |
||||
$section->drawOnBack = true; |
||||
$section->clearBeforeDrawing = true; |
||||
$section->elements = $elements; |
||||
|
||||
$file = new monoformat\File(); |
||||
array_push($file->sections, $section); |
||||
|
||||
Header("Content-Type: application/octet-stream"); |
||||
|
||||
print($file->serialize()); |
||||
} // global |
||||
|
||||
?> |
||||
@ -0,0 +1,203 @@ |
||||
<?php |
||||
|
||||
namespace monoformat { |
||||
|
||||
enum SectionType : int { |
||||
case AlwaysDrawn = 1; |
||||
case TimeBasedDrawn = 2; |
||||
case CustomFont = 32; |
||||
} |
||||
|
||||
enum ElementType : int { |
||||
case Image = 1; |
||||
case Animation = 2; |
||||
case HScrollImage = 3; |
||||
case VScrollImage = 4; |
||||
case Line = 5; |
||||
case ClippedText = 16; |
||||
case HScrollText = 17; |
||||
case CurrentTime = 32; |
||||
} |
||||
|
||||
enum LineStyle : int { |
||||
case Solid = 0; |
||||
} |
||||
|
||||
class Element { |
||||
public function serialize() { |
||||
return ""; |
||||
} |
||||
} |
||||
|
||||
class HScrollTextElement extends Element { |
||||
public $x = 0; |
||||
public $y = 0; |
||||
public $width = 0; |
||||
public $height = 0; |
||||
public $flags = 0; |
||||
public $scrollSpeed = 0; |
||||
public $fontIndex = 0; |
||||
public $text = ""; |
||||
|
||||
public function serialize() { |
||||
$len = strlen($this->text); |
||||
$result = pack("vvvvvCCvv", |
||||
17, |
||||
$this->x, |
||||
$this->y, |
||||
$this->width, |
||||
$this->height, |
||||
$this->flags, |
||||
$this->scrollSpeed, |
||||
$this->fontIndex, |
||||
$len); |
||||
$result .= (string) $this->text; |
||||
if ($len % 4 != 0) { |
||||
$n = 4 - ($len % 4); |
||||
for ($i = 0; $i < $n; ++$i) { |
||||
$result .= chr(0); |
||||
} |
||||
} |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class CurrentTimeElement extends Element { |
||||
public $x = 0; |
||||
public $y = 0; |
||||
public $width = 0; |
||||
public $height = 0; |
||||
public $fontIndex = 0; |
||||
public $utcOffset = 0; |
||||
public $flags = 0; |
||||
|
||||
public function serialize() { |
||||
$result = pack("vvvvvvvv", |
||||
32, |
||||
$this->x, |
||||
$this->y, |
||||
$this->width, |
||||
$this->height, |
||||
$this->fontIndex, |
||||
$this->utcOffset, |
||||
$this->flags); |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
class Section { |
||||
public function serialize() { |
||||
return ""; |
||||
} |
||||
} |
||||
|
||||
class AlwaysDrawnSection extends Section { |
||||
public $drawOnFront = false; |
||||
public $drawOnBack = false; |
||||
public $clearBeforeDrawing = false; |
||||
public $elements = []; |
||||
|
||||
public function serialize() { |
||||
$flags = 0; |
||||
if ($this->drawOnFront) { |
||||
$flags |= 0x01; |
||||
} |
||||
if ($this->drawOnBack) { |
||||
$flags |= 0x02; |
||||
} |
||||
if ($this->clearBeforeDrawing) { |
||||
$flags |= 0x04; |
||||
} |
||||
$inner = pack("vv", $flags, count($this->elements)); |
||||
foreach ($this->elements as $element) { |
||||
$inner .= $element->serialize(); |
||||
} |
||||
$len = strlen($inner) + 4; |
||||
$len = substr(pack("V", $len), 0, 3); |
||||
return pack("C", 1) . $len . $inner; |
||||
} |
||||
} |
||||
|
||||
class File { |
||||
public $sections = []; |
||||
|
||||
public function serialize() { |
||||
$nSections = count($this->sections); |
||||
$result = "\xAF\x7E\x2B\x63"; |
||||
$result .= pack("Vvv", 1, $nSections, 0); |
||||
foreach ($this->sections as $section) { |
||||
$result .= $section->serialize(); |
||||
} |
||||
return $result; |
||||
} |
||||
} |
||||
|
||||
} // namespace monoformat |
||||
|
||||
namespace { |
||||
|
||||
$roomName= "ENUM"; |
||||
|
||||
$data = json_decode(file_get_contents("https://cfp.cttue.de/tdf5/schedule/export/schedule.json"), true); |
||||
$talks = []; |
||||
$now = new DateTimeImmutable("now"); |
||||
foreach ($data["schedule"]["conference"]["days"] as $day) { |
||||
foreach ($day["rooms"][$roomName] as $t) { |
||||
[$h, $m] = explode(":", $t["duration"]); |
||||
$duration = $h * 3600 + $m * 60; |
||||
$duration = DateInterval::createFromDateString("$duration sec"); |
||||
$talk = [ |
||||
"date" => new DateTimeImmutable($t["date"]), |
||||
"duration" => $duration, |
||||
"title" => $t["title"], |
||||
]; |
||||
$talkEnd = $talk["date"]->add($talk["duration"]); |
||||
if ($talkEnd < $now) { |
||||
continue; |
||||
} |
||||
if ($talk["date"] > $now && count($talks) > 2) { |
||||
break; |
||||
} |
||||
array_push($talks, $talk); |
||||
} |
||||
} |
||||
|
||||
$elements = []; |
||||
|
||||
function putText($x, $y, $width, $height, $text) { |
||||
global $elements; |
||||
|
||||
$e = new monoformat\HScrollTextElement(); |
||||
$e->x = $x; |
||||
$e->y = $y; |
||||
$e->width = $width; |
||||
$e->height = $height; |
||||
$e->flags = 0; |
||||
$e->scrollSpeed = 15; |
||||
$e->fontIndex = 0; |
||||
$e->text = $text; |
||||
array_push($elements, $e); |
||||
} |
||||
|
||||
$i = 0; |
||||
foreach ($talks as $talk) { |
||||
putText(10, $i * 20, 25, 20, $talk["date"]->format("H:i")); |
||||
putText(35, $i * 20, 85, 20, $talk["title"]); |
||||
++$i; |
||||
} |
||||
|
||||
$section = new monoformat\AlwaysDrawnSection(); |
||||
$section->drawOnFront = true; |
||||
$section->drawOnBack = true; |
||||
$section->clearBeforeDrawing = true; |
||||
$section->elements = $elements; |
||||
|
||||
$file = new monoformat\File(); |
||||
array_push($file->sections, $section); |
||||
|
||||
Header("Content-Type: application/octet-stream"); |
||||
|
||||
print($file->serialize()); |
||||
} // global |
||||
|
||||
?> |
||||
@ -0,0 +1,53 @@ |
||||
<?php |
||||
|
||||
$path = 'out'; |
||||
$files = scandir($path); |
||||
$binaryFiles = []; |
||||
|
||||
foreach($files as $file) { |
||||
if(str_ends_with($file, 'bin')) { |
||||
$binaryFiles[] = $file; |
||||
} |
||||
} |
||||
|
||||
$current = date('i') % sizeof($binaryFiles); |
||||
|
||||
$downloadName = $binaryFiles[$current]; |
||||
$filePath = __DIR__ . '/out/' . $downloadName; |
||||
if (!is_file($filePath) || !is_readable($filePath)) { |
||||
http_response_code(404); |
||||
echo 'File not found.'; |
||||
echo $filePath; |
||||
exit; |
||||
} |
||||
|
||||
header('Content-Description: File Transfer'); |
||||
header('Content-Type: application/octet-stream'); // generic binary MIME |
||||
header('Content-Disposition: attachment; filename="' . basename($downloadName) . '"'); |
||||
header('Content-Transfer-Encoding: binary'); |
||||
header('Expires: 0'); |
||||
header('Cache-Control: must-revalidate, private'); |
||||
header('Pragma: public'); |
||||
|
||||
header('Content-Length: ' . filesize($filePath)); |
||||
|
||||
while (ob_get_level()) { |
||||
ob_end_clean(); |
||||
} |
||||
|
||||
$chunkSize = 8192; |
||||
$handle = fopen($filePath, 'rb'); |
||||
if ($handle === false) { |
||||
http_response_code(500); |
||||
echo 'Unable to open file.'; |
||||
exit; |
||||
} |
||||
|
||||
while (!feof($handle)) { |
||||
// Output a chunk and flush it immediately |
||||
echo fread($handle, $chunkSize); |
||||
flush(); // push to client |
||||
} |
||||
fclose($handle); |
||||
exit; |
||||
?> |
||||
@ -0,0 +1,84 @@ |
||||
body { |
||||
font-family: sans-serif; |
||||
background: #111; |
||||
color: white; |
||||
display: flex; |
||||
flex-direction: column; |
||||
align-items: center; |
||||
} |
||||
|
||||
canvas { |
||||
image-rendering: pixelated; |
||||
margin-top: 20px; |
||||
} |
||||
|
||||
#matrix { |
||||
display: grid; |
||||
grid-template-columns: repeat(var(--col-count, 10), 1fr); |
||||
grid-gap: 1px; |
||||
margin-top: 20px; |
||||
} |
||||
|
||||
.dot { |
||||
width: 10px; |
||||
height: 10px; |
||||
background: #222; |
||||
border-radius: 50%; |
||||
} |
||||
|
||||
.dot.on { |
||||
background: orange; |
||||
} |
||||
|
||||
#dropzone { |
||||
border: 2px dashed #555; |
||||
padding: 20px; |
||||
margin-top: 20px; |
||||
text-align: center; |
||||
width: 300px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
#controls { |
||||
margin-top: 10px; |
||||
display: flex; |
||||
gap: 10px; |
||||
flex-wrap: wrap; |
||||
justify-content: center; |
||||
} |
||||
|
||||
#textInputArea { |
||||
display: none; |
||||
margin-top: 10px; |
||||
} |
||||
|
||||
input, textarea, select { |
||||
background: #222; |
||||
color: white; |
||||
border: 1px solid #555; |
||||
padding: 5px; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
select option { |
||||
background: #222; |
||||
color: white; |
||||
} |
||||
|
||||
button { |
||||
background: #333; |
||||
color: white; |
||||
border: 1px solid #555; |
||||
padding: 5px 10px; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
} |
||||
|
||||
button:hover { |
||||
background: #444; |
||||
} |
||||
|
||||
button.active { |
||||
background: #f60; |
||||
border-color: #f60; |
||||
} |
||||
@ -0,0 +1,105 @@ |
||||
<?php |
||||
|
||||
$width = 120; |
||||
$height = 60; |
||||
$PASSWORD = "Ky/6lG3kbA>/TM?C(V@S"; |
||||
|
||||
// --- Helpers --- |
||||
function setPixel(int $x, int $y, int $width, &$bitmap) { |
||||
if ($x < 0 || $y < 0) return; |
||||
$index = $y * $width + $x; |
||||
$byteIdx = intdiv($index, 8); |
||||
$bitPos = $index % 8; |
||||
$bitmap[$byteIdx] |= (1 << $bitPos); |
||||
} |
||||
|
||||
function createImageBitmap(array $pixelsOn, int $width, int $height): array { |
||||
$totalBits = $width * $height; |
||||
$bitmapSize = intdiv($totalBits + 7, 8); |
||||
$bitmap = array_fill(0, $bitmapSize, 0); |
||||
foreach ($pixelsOn as [$x, $y]) { |
||||
setPixel($x, $y, $width, $bitmap); |
||||
} |
||||
return $bitmap; |
||||
} |
||||
|
||||
function packBitmap(array $bitmap): string { |
||||
return implode('', array_map(fn($b) => pack('C', $b), $bitmap)); |
||||
} |
||||
|
||||
function createImageObject(int $width, int $height, array $bitmap, int $xOffset = 0, int $yOffset = 0): string { |
||||
$payload = pack('C', $width); |
||||
$payload .= pack('C', $height); |
||||
$payload .= packBitmap($bitmap); |
||||
$header = pack('C*', 0x01, $xOffset, $yOffset); // Type = 1 |
||||
return $header . $payload; |
||||
} |
||||
|
||||
function createScrollObject(int $xOffset, int $yOffset, int $width, int $height, int $contentWidth, array $bitmap, int $speed = 0, bool $directionRight = false, bool $wrap = true): string { |
||||
$CW = pack('v', $contentWidth); |
||||
$UF = (($wrap ? 1 : 0) << 7) | (($directionRight ? 1 : 0) << 6) | ($speed & 0x3F); |
||||
$payload = pack('C', $width); |
||||
$payload .= pack('C', $height); |
||||
$payload .= $CW; |
||||
$payload .= pack('C', $UF); |
||||
$payload .= packBitmap($bitmap); |
||||
$header = pack('C*', 0x03, $xOffset, $yOffset); |
||||
return $header . $payload; |
||||
} |
||||
|
||||
function createBlob(array $objects, string $fileName) { |
||||
$blob = ''; |
||||
$blob .= pack('C*', 0x42, 0x4E, 0x17, 0xEE); |
||||
$blob .= pack('C', count($objects)); |
||||
foreach ($objects as $obj) { |
||||
$blob .= $obj; |
||||
} |
||||
file_put_contents($fileName, $blob); |
||||
echo "Blob written to {$fileName} (" . strlen($blob) . " bytes)\n"; |
||||
} |
||||
|
||||
// --- Validation --- |
||||
if (!isset($_POST['password'])) die("password missing."); |
||||
if (htmlspecialchars($_POST['password']) !== htmlspecialchars($PASSWORD)) die("wrong password."); |
||||
|
||||
// --- Determine mode --- |
||||
$mode = $_POST['mode'] ?? 'image'; |
||||
$objects = []; |
||||
$fileName = 'out/' . $_POST['fileName'] . '.bin' ?? 'out/' .'output.bin'; |
||||
|
||||
mkdir('out/'); |
||||
// --- Handle Image Mode --- |
||||
if ($mode === 'image') { |
||||
$pixelsOn = json_decode($_POST['array_output'] ?? '[]', true); |
||||
if (!is_array($pixelsOn)) die("invalid or missing array_output for image mode."); |
||||
$bitmap = createImageBitmap($pixelsOn, $width, $height); |
||||
$objects[] = createImageObject($width, $height, $bitmap, 0, 0); |
||||
} |
||||
|
||||
// --- Handle Text Mode (scrolling text) --- |
||||
elseif ($mode === 'text') { |
||||
$scrolls = json_decode($_POST['scrolls'] ?? '[]', true); |
||||
if (!is_array($scrolls) || empty($scrolls)) die("invalid or missing scrolls for text mode."); |
||||
|
||||
foreach ($scrolls as $scroll) { |
||||
$pixelsOn = $scroll['pixelsOn'] ?? []; |
||||
$bitmap = createImageBitmap($pixelsOn, $scroll['contentWidth'], $scroll['height']); |
||||
$objects[] = createScrollObject( |
||||
$scroll['x'] ?? 0, |
||||
$scroll['y'] ?? 0, |
||||
$scroll['width'] ?? $width, |
||||
$scroll['height'] ?? 8, |
||||
$scroll['contentWidth'] ?? 60, |
||||
$bitmap, |
||||
$scroll['speed'] ?? 0, |
||||
$scroll['directionRight'] ?? false, |
||||
$scroll['wrap'] ?? true |
||||
); |
||||
} |
||||
} |
||||
|
||||
else { |
||||
die("unknown mode: {$mode}"); |
||||
} |
||||
|
||||
createBlob($objects, $fileName); |
||||
@ -1,68 +0,0 @@ |
||||
@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%; |
||||
} |
||||
} |
||||