396 lines
18 KiB
HTML
396 lines
18 KiB
HTML
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8" />
|
||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||
<title>One‑Liner Painter (Multi‑Stroke) – 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>One‑Liner Painter (Multi‑Stroke) → 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/Stop‑Marker</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>Stroke‑Farbe A</span></label>
|
||
<label class="chip"><input type="radio" name="mode" value="B"> <span>Stroke‑Farbe 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 (JSON‑only)</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="Strich‑Liste"></ul>
|
||
<p class="muted">Neuer Stroke beginnt mit <span class="kbd">Maus/Touch‑Down</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">Motion‑JSON 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 – One‑Liner"></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 – One‑Liner</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 Motion‑JSON.</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() || 'One‑Liner';
|
||
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('Motion‑JSON 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>
|