feat(rc3): Crumbblocks UI Mission & Smart Routing 🎨

This commit is contained in:
Branko May Trinkwald
2025-12-23 20:52:00 +01:00
parent e52684d6f8
commit 01a01f53b4
27 changed files with 7551 additions and 25 deletions

View File

@@ -0,0 +1,395 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>OneLiner Painter (MultiStroke) draw → export SVG + Motion JSON</title>
<style>
:root{
--bg: #0b0b10; --panel: #12121a; --muted:#8d93a1; --accent:#62d3a4; --accent2:#ffd166; --danger:#ff5c5c;
}
*{box-sizing:border-box}
html,body{height:100%}
body{margin:0;display:grid;grid-template-rows:auto 1fr auto;background:var(--bg);color:#e7eaf0;font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial}
header,footer{padding:12px 16px;background:var(--panel);border-bottom:1px solid #1e2230}
footer{border-top:1px solid #1e2230;border-bottom:none}
h1{font-size:1.05rem;margin:0}
.wrap{display:grid;grid-template-columns:340px 1fr;gap:16px;padding:16px}
@media (max-width:900px){.wrap{grid-template-columns:1fr}}
.panel{background:var(--panel);border:1px solid #1e2230;border-radius:12px;padding:14px;display:grid;gap:12px}
label{font-size:.9rem;color:#cfd5e3}
input[type="text"],textarea,select{width:100%;padding:10px;border-radius:10px;border:1px solid #2a3042;background:#0f121a;color:#e7eaf0}
input[type="color"]{width:48px;height:36px;border:none;background:none}
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.btn{appearance:none;border:none;border-radius:10px;padding:10px 12px;background:#1f2636;color:#e7eaf0;cursor:pointer}
.btn:hover{background:#28314a}
.btn.accent{background:var(--accent);color:#0f131a;font-weight:600}
.btn.warn{background:var(--danger);color:#0f0f10}
.muted{color:var(--muted);font-size:.85rem}
.canvasWrap{position:relative;background:#0f121a;border:1px solid #1e2230;border-radius:12px;overflow:hidden}
svg{display:block;width:100%;height:100%;background:#0f121a}
.kbd{padding:1px 6px;border-radius:8px;background:#1e2333;color:#cfd5e3;font-family:ui-monospace,Menlo,Monaco,monospace}
ul#strokeList{list-style:none;margin:0;padding:0;display:grid;gap:8px;max-height:220px;overflow:auto}
li.strokeItem{display:flex;align-items:center;gap:8px;background:#0f121a;border:1px solid #2a3042;border-radius:10px;padding:8px}
.chip{display:inline-flex;align-items:center;gap:8px;background:#0f121a;border:1px dashed #2a3042;border-radius:10px;padding:6px 10px}
</style>
</head>
<body>
<header>
<h1>OneLiner Painter (MultiStroke) → SVG & Motion JSON • a→Spirale→y • Start/Stop/Dynamik</h1>
</header>
<div class="wrap">
<aside class="panel" aria-labelledby="controlsTitle">
<h2 id="controlsTitle" style="margin:0;font-size:1rem">Werkzeug</h2>
<div class="row">
<label class="chip"><input type="checkbox" id="sparkMode"> <span>Funken setzen (Shift)</span></label>
<label class="chip"><input type="checkbox" id="bitMode"> <span>Bits 1+1 setzen</span></label>
<label class="chip"><input type="checkbox" id="markers"> <span>Start/StopMarker</span></label>
</div>
<div class="row">
<label>Strichstärke
<input type="range" id="stroke" min="4" max="28" step="1" value="12">
</label>
<label>Glättung
<input type="range" id="smooth" min="0" max="10" step="1" value="2">
</label>
</div>
<div class="row">
<label>Farbe A <input type="color" id="colorA" value="#62d3a4"></label>
<label>Farbe B <input type="color" id="colorB" value="#ffd166"></label>
<label>Funken <input type="color" id="colorSpark" value="#e7eaf0"></label>
</div>
<div class="row">
<label class="chip"><input type="radio" name="mode" value="A" checked> <span>StrokeFarbe A</span></label>
<label class="chip"><input type="radio" name="mode" value="B"> <span>StrokeFarbe B</span></label>
<label class="chip"><input type="radio" name="mode" value="GRAD"> <span>Verlauf A→B</span></label>
</div>
<div class="row">
<label class="chip"><input type="checkbox" id="dynWidth"> <span>Breite ~ Geschwindigkeit (JSONonly)</span></label>
<label class="chip"><input type="checkbox" id="captureJSON" checked> <span>Motion JSON mitschreiben</span></label>
</div>
<div>
<strong>Strokes</strong>
<ul id="strokeList" aria-label="StrichListe"></ul>
<p class="muted">Neuer Stroke beginnt mit <span class="kbd">Maus/TouchDown</span>. Ende bei Loslassen. Mehrere Klicks/Wege werden einzeln erfasst.</p>
</div>
<div class="row">
<button class="btn" id="undo">Letzten Stroke löschen</button>
<button class="btn warn" id="clear">Alles löschen</button>
</div>
<div class="row">
<button class="btn" id="copySVG">SVG kopieren</button>
<button class="btn" id="downloadSVG">SVG speichern</button>
<button class="btn accent" id="copyJSON">MotionJSON kopieren</button>
<button class="btn" id="downloadJSON">JSON speichern</button>
</div>
<details>
<summary class="muted">A11y/Meta</summary>
<label>Titel <input id="title" type="text" value="Omi Omega OneLiner"></label>
<label>Beschreibung
<textarea id="desc" rows="3">Cursives kleines a wird zur Spirale; zwei Einsen im a; y hält zusammen; Funken ringsum.</textarea>
</label>
<label>Tag (z.B. Datum/Hashtag) <input id="tag" type="text" value="22.08.25 #CRUMB"></label>
</details>
<p class="muted">Tipps: Ziehen = zeichnen. <span class="kbd">Shift</span> = Funken. <span class="kbd">Z</span> = Undo. <span class="kbd">S</span> speichern.</p>
</aside>
<main class="canvasWrap" aria-label="Zeichenfläche">
<svg id="stage" viewBox="0 0 1200 800" role="img" aria-labelledby="svgTitle svgDesc">
<title id="svgTitle">Omi Omega OneLiner</title>
<desc id="svgDesc">Mehrere Pfade mit Start/Stop und Zeitdynamik: a→Spirale→y; Bits 1+1; Funken.</desc>
<defs>
<linearGradient id="gradA2B" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#62d3a4" />
<stop offset="50%" stop-color="#62d3a4" />
<stop offset="51%" stop-color="#ffd166" />
<stop offset="100%" stop-color="#ffd166" />
</linearGradient>
<style>
.sline{fill:none;stroke-linecap:round;stroke-linejoin:round}
.markerStart{fill:#35c759;stroke:none}
.markerStop{fill:#ff3b30;stroke:none}
</style>
</defs>
<g id="art">
<g id="strokes"></g>
<g id="bits"></g>
<g id="sparks" stroke="#e7eaf0" stroke-width="8" stroke-linecap="round"></g>
<g id="markersG"></g>
<text id="tagText" x="860" y="760" font-family="ui-monospace,monospace" font-size="22" fill="#cfd5e3"></text>
</g>
</svg>
</main>
</div>
<footer>
<span class="muted">Mehrere Klicks & Pfade sind erlaubt. Start/Stop wird als Ereignis erfasst; Geschwindigkeiten landen im MotionJSON.</span>
</footer>
<script>
(function(){
const stage = document.getElementById('stage');
const strokesG= document.getElementById('strokes');
const bitsG = document.getElementById('bits');
const sparksG = document.getElementById('sparks');
const markersG= document.getElementById('markersG');
const grad = document.getElementById('gradA2B');
const tagText = document.getElementById('tagText');
const strokeList = document.getElementById('strokeList');
// Controls
const sparkMode = document.getElementById('sparkMode');
const bitMode = document.getElementById('bitMode');
const markersCb = document.getElementById('markers');
const strokeInp = document.getElementById('stroke');
const smoothInp = document.getElementById('smooth');
const colorAInp = document.getElementById('colorA');
const colorBInp = document.getElementById('colorB');
const colorSInp = document.getElementById('colorSpark');
const dynWidth = document.getElementById('dynWidth');
const captureJSON = document.getElementById('captureJSON');
const titleInp = document.getElementById('title');
const descInp = document.getElementById('desc');
const tagInp = document.getElementById('tag');
const undoBtn = document.getElementById('undo');
const clearBtn = document.getElementById('clear');
const copySVGBtn = document.getElementById('copySVG');
const dlSVGBtn = document.getElementById('downloadSVG');
const copyJSONBtn = document.getElementById('copyJSON');
const dlJSONBtn = document.getElementById('downloadJSON');
let mode = 'A';
document.querySelectorAll('input[name="mode"]').forEach(r=>{
r.addEventListener('change', ()=>{ mode = document.querySelector('input[name="mode"]:checked').value; });
});
// State
let drawing = false;
let curStroke = null; // {id, mode, color, width, points:[{x,y,t}], pathEl}
const strokes = []; // array of strokes
let t0 = null; // session start time
function now(){ return performance.now(); }
function svgPoint(evt){
const pt = stage.createSVGPoint();
pt.x = evt.clientX; pt.y = evt.clientY;
const ctm = stage.getScreenCTM().inverse();
const p = pt.matrixTransform(ctm);
return {x: Math.round(p.x), y: Math.round(p.y)};
}
function updateMeta(){
stage.querySelector('title').textContent = titleInp.value.trim() || 'OneLiner';
stage.querySelector('desc').textContent = descInp.value.trim() || '';
tagText.textContent = tagInp.value.trim();
}
function listRefresh(){
strokeList.innerHTML = '';
strokes.forEach((s,i)=>{
const li = document.createElement('li'); li.className='strokeItem';
const sw = document.createElement('input'); sw.type='range'; sw.min=4; sw.max=28; sw.step=1; sw.value=s.width; sw.title='Breite';
sw.addEventListener('input',()=>{ s.width=+sw.value; s.pathEl.setAttribute('stroke-width', s.width); });
const sel = document.createElement('select');
['A','B','GRAD'].forEach(v=>{ const o=document.createElement('option'); o.value=v; o.textContent=v; if(s.mode===v) o.selected=true; sel.appendChild(o); });
sel.addEventListener('change',()=>{ s.mode=sel.value; applyStrokeStyle(s); });
const del = document.createElement('button'); del.className='btn'; del.textContent='✕';
del.addEventListener('click',()=>{ removeStroke(i); });
const meta = document.createElement('span'); meta.className='muted';
meta.textContent = `#${s.id}${Math.round(s.duration)}ms • ~${Math.round(s.length)}px`;
li.append('Stroke', sel, sw, del, meta);
strokeList.appendChild(li);
});
}
function removeStroke(idx){
const s = strokes[idx];
if(!s) return;
s.pathEl.remove(); if(s.startMarker) s.startMarker.remove(); if(s.stopMarker) s.stopMarker.remove();
strokes.splice(idx,1);
listRefresh();
}
function createPathEl(){
const p = document.createElementNS('http://www.w3.org/2000/svg','path');
p.setAttribute('class','sline');
p.setAttribute('stroke-width', strokeInp.value);
strokesG.appendChild(p);
return p;
}
function applyStrokeStyle(s){
if(s.mode==='A'){ s.pathEl.setAttribute('stroke', colorAInp.value); }
else if(s.mode==='B'){ s.pathEl.setAttribute('stroke', colorBInp.value); }
else { s.pathEl.setAttribute('stroke','url(#gradA2B)'); }
}
function toPathD(points){
if(points.length===0) return '';
const sm = +smoothInp.value;
if(points.length<3 || sm===0){
const p0 = points[0];
let d = `M ${p0.x} ${p0.y}`;
for(let i=1;i<points.length;i++){ const p=points[i]; d += ` L ${p.x} ${p.y}`; }
return d;
}
// simple smoothing: use quadratic Beziers between midpoints
let d = `M ${points[0].x} ${points[0].y}`;
for(let i=1;i<points.length-1;i++){
const p0 = points[i];
const p1 = points[i+1];
const mx = (p0.x + p1.x)/2; const my = (p0.y + p1.y)/2;
d += ` Q ${p0.x} ${p0.y} ${mx} ${my}`;
}
const last = points[points.length-1]; d += ` L ${last.x} ${last.y}`;
return d;
}
function startStroke(e){
if(bitMode.checked){ placeBit(e); return; }
if(sparkMode.checked || e.shiftKey){ placeSpark(e); return; }
drawing = true;
if(t0===null) t0 = now();
const t = now() - t0;
const pt = svgPoint(e);
const pathEl = createPathEl();
curStroke = { id: Date.now()%1e7, mode, colorA: colorAInp.value, colorB: colorBInp.value, width:+strokeInp.value, points:[{...pt,t}], pathEl, start:t, duration:0, length:0 };
applyStrokeStyle(curStroke);
updatePath(curStroke);
if(markersCb.checked){ curStroke.startMarker = mark(pt.x, pt.y, 'start'); }
window.addEventListener('pointerup', endStroke, {once:true});
}
function moveStroke(e){
if(!drawing || !curStroke) return;
const p = svgPoint(e);
const t = now() - t0;
const last = curStroke.points[curStroke.points.length-1];
if(!last || Math.hypot(p.x-last.x, p.y-last.y) > 2){
curStroke.points.push({...p,t});
updatePath(curStroke);
}
}
function endStroke(){
if(!curStroke) return;
drawing = false;
const pts = curStroke.points;
curStroke.duration = pts.length? (pts[pts.length-1].t - pts[0].t) : 0;
curStroke.length = polyLen(pts);
if(markersCb.checked){ const last=pts[pts.length-1]; curStroke.stopMarker = mark(last.x,last.y,'stop'); }
strokes.push(curStroke); curStroke = null; listRefresh();
}
function mark(x,y,type){
const r = 7; const c = document.createElementNS('http://www.w3.org/2000/svg','circle');
c.setAttribute('cx',x); c.setAttribute('cy',y); c.setAttribute('r',r);
c.setAttribute('class', type==='start'?'markerStart':'markerStop');
markersG.appendChild(c); return c;
}
function updatePath(s){ s.pathEl.setAttribute('d', toPathD(s.points)); s.pathEl.setAttribute('stroke-width', s.width); }
function polyLen(pts){ let L=0; for(let i=1;i<pts.length;i++){ const dx=pts[i].x-pts[i-1].x, dy=pts[i].y-pts[i-1].y; L += Math.hypot(dx,dy);} return L; }
function placeSpark(e){
const p = svgPoint(e);
const len = Math.max(14, parseInt(strokeInp.value,10)*1.5);
const dx = 10, dy = -10;
const l = document.createElementNS('http://www.w3.org/2000/svg','line');
l.setAttribute('x1', p.x); l.setAttribute('y1', p.y);
l.setAttribute('x2', p.x+dx); l.setAttribute('y2', p.y+dy);
l.setAttribute('stroke', colorSInp.value);
l.setAttribute('stroke-width', Math.max(2, Math.round(parseInt(strokeInp.value,10)*0.66)));
l.setAttribute('stroke-linecap','round');
sparksG.appendChild(l);
}
function placeBit(e){
const p = svgPoint(e);
const h = Math.max(18, parseInt(strokeInp.value,10)*1.2);
const w = Math.max(6, parseInt(strokeInp.value,10)*0.8);
const line1 = document.createElementNS('http://www.w3.org/2000/svg','line');
line1.setAttribute('x1', p.x); line1.setAttribute('y1', p.y-h/2);
line1.setAttribute('x2', p.x); line1.setAttribute('y2', p.y+h/2);
line1.setAttribute('stroke', colorBInp.value);
line1.setAttribute('stroke-width', w);
line1.setAttribute('stroke-linecap', 'round');
bitsG.appendChild(line1);
}
function exportSVGString(){
updateMeta();
const clone = stage.cloneNode(true);
// inline live colors
clone.querySelector('#sparks')?.setAttribute('stroke', colorSInp.value);
// serialize
const s = new XMLSerializer().serializeToString(clone);
return `<?xml version="1.0" encoding="UTF-8"?>
${s}`;
}
function motionJSON(){
const meta = { title:titleInp.value, desc:descInp.value, tag:tagInp.value, t0: t0??0 };
const items = strokes.map(s=>{
// speeds
let maxV=0, sumV=0, nV=0; const pts=s.points;
for(let i=1;i<pts.length;i++){
const dx=pts[i].x-pts[i-1].x, dy=pts[i].y-pts[i-1].y; const dt=(pts[i].t-pts[i-1].t)/1000; if(dt<=0) continue;
const v = Math.hypot(dx,dy)/dt; maxV=Math.max(maxV,v); sumV+=v; nV++;
}
const avgV = nV? sumV/nV : 0;
return {
id: s.id, mode: s.mode, colorA: s.colorA, colorB: s.colorB, width: s.width,
startMs: s.start, durationMs: s.duration, lengthPx: s.length,
avgSpeedPxPerS: +avgV.toFixed(2), maxSpeedPxPerS: +maxV.toFixed(2),
points: s.points.map(p=>({x:p.x,y:p.y,tMs: Math.round(p.t)}))
};
});
return JSON.stringify({meta, strokes:items}, null, 2);
}
// Buttons
document.getElementById('undo').addEventListener('click', ()=>{ if(strokes.length){ removeStroke(strokes.length-1); }});
document.getElementById('clear').addEventListener('click', ()=>{
strokes.length=0; strokesG.innerHTML=''; bitsG.innerHTML=''; sparksG.innerHTML=''; markersG.innerHTML=''; listRefresh(); t0=null; });
copySVGBtn.addEventListener('click', async ()=>{ const svg=exportSVGString(); await navigator.clipboard.writeText(svg); alert('SVG kopiert'); });
dlSVGBtn.addEventListener('click', ()=>{ const svg=exportSVGString(); const blob=new Blob([svg],{type:'image/svg+xml'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='omi-omega-oneliner_multi.svg'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1500); });
copyJSONBtn.addEventListener('click', async ()=>{ const js=motionJSON(); await navigator.clipboard.writeText(js); alert('MotionJSON kopiert'); });
dlJSONBtn.addEventListener('click', ()=>{ const js=motionJSON(); const blob=new Blob([js],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='omi-omega_motion.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1500); });
// Stage events
stage.addEventListener('pointerdown', startStroke);
stage.addEventListener('pointermove', moveStroke);
window.addEventListener('pointerup', endStroke);
// Hotkeys
window.addEventListener('keydown', (e)=>{
if(e.key==='z' || e.key==='Z'){ if(strokes.length){ removeStroke(strokes.length-1); } }
if((e.key==='s' || e.key==='S') && (e.ctrlKey||e.metaKey)){ e.preventDefault(); document.getElementById('downloadSVG').click(); }
});
// Reactive
[titleInp,descInp,tagInp].forEach(inp=>inp.addEventListener('input', updateMeta));
[colorAInp,colorBInp,colorSInp].forEach(inp=>inp.addEventListener('input', ()=>{ tagText.setAttribute('fill','#cfd5e3'); }));
updateMeta();
})();
</script>
</body>
</html>