Files
2026-04-16 23:00:25 +02:00

1933 lines
60 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8">
<title>Trakodag Web — Code Image Editor</title>
<style>
:root {
--bg: #14171c;
--bg2: #1b1f27;
--bg3: #232936;
--fg: #e6e6e6;
--fg-dim: #98a0ad;
--accent: #6cf;
--accent2: #ffb86c;
--danger: #ff6b6b;
--ok: #7ee787;
--border: #2a3142;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%;
background: var(--bg); color: var(--fg);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
font-size: 13px;
overflow: hidden;
}
header {
display: flex; align-items: center; gap: 10px;
padding: 8px 12px;
background: var(--bg2);
border-bottom: 1px solid var(--border);
height: 46px;
}
header .title {
font-weight: 700; letter-spacing: 0.5px;
color: var(--accent);
margin-right: 8px;
}
header .title span { color: var(--accent2); }
header .sep { width: 1px; height: 24px; background: var(--border); margin: 0 4px; }
button, label.btn, select, input[type="number"], input[type="text"] {
background: var(--bg3);
color: var(--fg);
border: 1px solid var(--border);
padding: 5px 10px;
border-radius: 4px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
transition: background 0.15s, border 0.15s;
}
button:hover, label.btn:hover, select:hover { background: #2c3548; border-color: #3a455c; }
button:active { transform: translateY(1px); }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button:disabled:hover { background: var(--bg3); border-color: var(--border); }
button.primary { background: #2557a7; border-color: #2e6fd1; color: #fff; }
button.primary:hover { background: #2e6fd1; }
button.danger { background: #5b2222; border-color: #7a3333; }
button.danger:hover { background: #7a3333; }
input[type="file"] { display: none; }
.spacer { flex: 1; }
.status { color: var(--fg-dim); font-size: 11px; padding: 0 8px; }
.status.err { color: var(--danger); }
.status.ok { color: var(--ok); }
main {
display: grid;
grid-template-columns: 1fr 460px;
height: calc(100vh - 46px);
width: 100vw;
}
/* === LEFT: canvas === */
#canvasPane {
position: relative;
background: #0c0e12;
overflow: hidden;
display: flex; align-items: center; justify-content: center;
background-image:
linear-gradient(45deg, #1a1d23 25%, transparent 25%),
linear-gradient(-45deg, #1a1d23 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, #1a1d23 75%),
linear-gradient(-45deg, transparent 75%, #1a1d23 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
#canvasWrap {
position: relative;
box-shadow: 0 0 40px rgba(0,0,0,0.5);
}
#canvasOut, #canvasOrig {
display: block;
image-rendering: pixelated;
max-width: 100%;
max-height: 100%;
}
#canvasOrig {
position: absolute; inset: 0;
opacity: 0; pointer-events: none;
transition: opacity 0.05s;
}
#canvasWrap.showOrig #canvasOrig { opacity: 1; }
#canvasWrap.showOrig::after {
content: "ORIGINAL";
position: absolute; top: 8px; left: 8px;
background: rgba(0,0,0,0.7);
color: var(--accent2);
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
pointer-events: none;
}
#canvasInfo {
position: absolute; bottom: 8px; left: 8px;
background: rgba(0,0,0,0.78);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
color: var(--fg);
pointer-events: none;
font-family: monospace;
max-width: calc(100% - 16px);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#canvasWrap { cursor: crosshair; }
#canvasWrap.showOrig { cursor: zoom-in; }
#emptyHint {
color: var(--fg-dim);
text-align: center;
line-height: 1.7;
}
#emptyHint strong { color: var(--accent); }
/* === RIGHT: editor === */
#rightPane {
display: grid;
grid-template-rows: auto auto 1fr auto;
background: var(--bg2);
border-left: 1px solid var(--border);
min-width: 0;
}
.panelHead {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px;
background: var(--bg3);
border-bottom: 1px solid var(--border);
font-size: 11px;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
}
.panelHead button { padding: 3px 8px; font-size: 11px; }
/* Layers */
#layersPanel {
max-height: 180px;
overflow-y: auto;
border-bottom: 1px solid var(--border);
}
.layer {
display: flex; flex-direction: column; gap: 4px;
padding: 6px 10px;
border-bottom: 1px solid #1c2230;
cursor: pointer;
background: var(--bg2);
}
.layer:hover { background: #1f2532; }
.layer.active { background: #2a3a52; }
.layer .topRow {
display: flex; align-items: center; gap: 6px;
width: 100%;
}
.layer .vis {
width: 18px; height: 18px; flex: 0 0 18px;
text-align: center; line-height: 18px;
user-select: none;
color: var(--fg-dim);
}
.layer .vis.on { color: var(--ok); }
.layer .name {
flex: 1; min-width: 0;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 12px;
}
.layer .name input {
background: var(--bg3); border: 1px solid var(--border);
color: var(--fg); padding: 2px 4px; font-size: 12px;
width: 100%;
}
.layer select, .layer input[type="number"] {
padding: 2px 4px; font-size: 11px;
}
.layer .opacityWrap {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding-left: 24px; /* align under name, past .vis */
}
.layer .opacityWrap .opLabel {
font-size: 10px;
color: var(--fg-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
flex: 0 0 auto;
}
.layer .opacitySlider {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 4px;
background: var(--bg3);
border-radius: 2px;
outline: none;
cursor: pointer;
margin: 0;
padding: 0;
}
.layer .opacitySlider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px; height: 12px;
background: var(--accent);
border-radius: 50%;
border: 1px solid #1a2030;
cursor: pointer;
}
.layer .opacitySlider::-moz-range-thumb {
width: 12px; height: 12px;
background: var(--accent);
border-radius: 50%;
border: 1px solid #1a2030;
cursor: pointer;
}
.layer .opacityVal {
font-family: monospace;
font-size: 10.5px;
color: var(--fg-dim);
min-width: 28px;
text-align: right;
}
.layer .actions { display: flex; gap: 2px; }
.layer .actions button { padding: 2px 6px; font-size: 11px; }
/* Images panel */
#imagesPanel {
border-bottom: 1px solid var(--border);
max-height: 110px;
overflow-y: auto;
}
#imagesList {
display: flex; gap: 6px; padding: 8px 10px;
flex-wrap: wrap;
}
.imgThumb {
position: relative;
width: 64px; height: 64px;
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
background: #000;
}
.imgThumb canvas { width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
.imgThumb .label {
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,0.75);
color: var(--accent);
font-size: 10px;
padding: 2px 4px;
text-align: center;
font-family: monospace;
}
.imgThumb .del {
position: absolute; top: 2px; right: 2px;
background: rgba(255,0,0,0.7);
color: #fff; border: none;
width: 16px; height: 16px; border-radius: 50%;
line-height: 14px; font-size: 11px; padding: 0;
cursor: pointer;
}
.imgEmpty { color: var(--fg-dim); padding: 8px 10px; font-size: 11px; }
/* Editor */
#editorWrap {
display: flex; flex-direction: column;
min-height: 0;
}
#editorBox {
position: relative;
flex: 1;
overflow: hidden;
background: #0e1117;
}
#editor, #highlight {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
margin: 0;
padding: 10px 12px;
font-family: "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.5;
tab-size: 2;
-moz-tab-size: 2;
white-space: pre;
border: none;
box-sizing: border-box;
overflow: auto;
}
#highlight {
color: #d6deeb;
pointer-events: none;
z-index: 1;
overflow: hidden;
}
#editor {
color: transparent;
background: transparent;
-webkit-text-fill-color: transparent;
caret-color: #ffffff;
resize: none;
outline: none;
z-index: 2;
}
#editor::selection { background: #2e6fd1; -webkit-text-fill-color: transparent; }
#editor::-moz-selection { background: #2e6fd1; -webkit-text-fill-color: transparent; }
/* syntax tokens */
.tk-cm { color: #6a737d; font-style: italic; }
.tk-str { color: #a5e075; }
.tk-num { color: var(--accent2); }
.tk-kw { color: #c792ea; font-weight: 600; }
.tk-api { color: var(--accent); font-weight: 600; }
.tk-fn { color: #82aaff; }
.tk-op { color: #89ddff; }
.tk-id { color: #d6deeb; }
/* Vars / API reference panel */
#varsPanel {
background: var(--bg2);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
max-height: 240px;
overflow-y: auto;
display: none;
}
#varsPanel.open { display: block; }
.varsGroup {
padding: 6px 10px 8px;
border-bottom: 1px dashed #1c2230;
}
.varsGroup:last-child { border-bottom: none; }
.varsGroup h5 {
margin: 0 0 4px;
font-size: 10px;
color: var(--accent2);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
}
.varsList {
display: flex; flex-wrap: wrap; gap: 4px;
}
.varChip {
display: inline-flex; align-items: center;
background: #0e1117;
border: 1px solid var(--border);
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 11px;
color: var(--accent);
cursor: pointer;
transition: background 0.1s, border 0.1s;
}
.varChip:hover {
background: #1a2030;
border-color: var(--accent);
}
.varChip .desc {
color: var(--fg-dim);
margin-left: 6px;
font-family: inherit;
font-size: 10.5px;
}
/* Console */
#console {
background: #0a0c10;
border-top: 1px solid var(--border);
padding: 6px 10px;
font-family: monospace;
font-size: 11px;
color: var(--fg-dim);
height: 100px;
overflow-y: auto;
white-space: pre-wrap;
}
#console .err { color: var(--danger); }
#console .ok { color: var(--ok); }
#console .info { color: var(--accent); }
/* misc */
.toggle {
display: inline-flex; align-items: center; gap: 5px;
color: var(--fg-dim); font-size: 11px;
}
.toggle input { accent-color: var(--accent); }
/* dialog popover */
.help {
position: absolute;
top: 50px; right: 470px;
background: var(--bg2);
border: 1px solid var(--border);
padding: 16px;
border-radius: 6px;
max-width: 460px;
max-height: 70vh;
overflow: auto;
z-index: 50;
font-size: 12px;
line-height: 1.55;
box-shadow: 0 12px 40px rgba(0,0,0,0.6);
display: none;
}
.help.open { display: block; }
.help h3 { margin: 0 0 8px; color: var(--accent); }
.help h4 { margin: 12px 0 4px; color: var(--accent2); }
.help code { background: #0a0c10; padding: 1px 5px; border-radius: 3px; color: #d6deeb; font-size: 11.5px; }
.help pre {
background: #0a0c10;
padding: 8px;
border-radius: 4px;
overflow-x: auto;
font-size: 11.5px;
font-family: "JetBrains Mono", "Fira Code", Menlo, Consolas, monospace;
line-height: 1.45;
color: #d6deeb;
white-space: pre;
tab-size: 2;
-moz-tab-size: 2;
}
.help .x { float: right; cursor: pointer; color: var(--fg-dim); }
/* modal */
.modal {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
z-index: 100;
display: none;
align-items: center;
justify-content: center;
}
.modal.open { display: flex; }
.modalCard {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
max-width: 540px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 12px 50px rgba(0,0,0,0.7);
}
.modalCard h3 { margin: 0 0 4px; color: var(--accent); }
.modalCard .sub { color: var(--fg-dim); font-size: 11.5px; margin-bottom: 10px; }
.presetItem {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
margin: 6px 0;
cursor: pointer;
background: var(--bg3);
transition: background 0.1s, border 0.1s;
}
.presetItem:hover { background: #2c3548; border-color: var(--accent); }
.presetItem h4 { margin: 0 0 4px; color: var(--accent); font-size: 13px; }
.presetItem p { margin: 0; color: var(--fg-dim); font-size: 11.5px; line-height: 1.45; }
.modalActions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 10px; }
</style>
</head>
<body>
<header>
<div class="title">trakodag<span>.web</span></div>
<label class="btn" for="fileMain">Load image</label>
<input type="file" id="fileMain" accept="image/*">
<label class="btn" for="fileExtra">+ Add image</label>
<input type="file" id="fileExtra" accept="image/*" multiple>
<div class="sep"></div>
<button id="runBtn" class="primary">Run ▶</button>
<label class="toggle"><input type="checkbox" id="autoRun" checked> auto-run</label>
<div class="sep"></div>
<button id="undoBtn" title="Undo (Ctrl/Cmd+Z)" disabled>↶ Undo</button>
<button id="redoBtn" title="Redo (Ctrl/Cmd+Y, Ctrl/Cmd+Shift+Z)" disabled>↷ Redo</button>
<div class="sep"></div>
<label class="toggle">scale
<select id="scaleSel">
<option value="1">1x</option>
<option value="0.5">0.5x</option>
<option value="0.25" selected>0.25x</option>
<option value="0.125">0.125x</option>
</select>
</label>
<label class="toggle">theme
<select id="themeSel">
<option value="blue">blue / orange</option>
<option value="purpleSea">purple / sea</option>
</select>
</label>
<div class="sep"></div>
<button id="exportBtn">Export PNG</button>
<div class="sep"></div>
<button id="saveProjectBtn" title="save layers + code as a .trakodag.json file">Save project</button>
<label class="btn" for="loadProjectFile" title="load layers + code from a .trakodag.json file">Load project</label>
<input type="file" id="loadProjectFile" accept=".json,application/json">
<button id="helpBtn">?</button>
<div class="spacer"></div>
<div id="status" class="status">no image loaded</div>
</header>
<main>
<div id="canvasPane">
<div id="emptyHint">
<strong>Load an image</strong> to start.<br>
Write code on the right — each layer is its own program.<br>
Hover the canvas to see the original.
</div>
<div id="canvasWrap" style="display:none">
<canvas id="canvasOut"></canvas>
<canvas id="canvasOrig"></canvas>
<div id="canvasInfo"></div>
</div>
</div>
<div id="rightPane">
<div class="panelHead">
Layers
<div style="flex:1"></div>
<button id="addLayer">+ Layer</button>
</div>
<div id="layersPanel"></div>
<div id="editorWrap">
<div class="panelHead">
Code <span style="text-transform:none;color:var(--fg-dim);font-weight:400;margin-left:6px" id="editorLabel"></span>
<div style="flex:1"></div>
<button id="varsBtn" title="show special variables you can use in code">ⓘ Vars</button>
<button id="resetCode">Reset to template</button>
</div>
<div id="editorBox">
<pre id="highlight" aria-hidden="true"></pre>
<textarea id="editor" spellcheck="false" wrap="off" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
</div>
<div id="varsPanel"></div>
</div>
<div class="panelHead">
Images
<div style="flex:1"></div>
<span style="text-transform:none;font-weight:400;color:var(--fg-dim)">access in code as <code>images[i]</code></span>
</div>
<div id="imagesPanel">
<div id="imagesList"><div class="imgEmpty">no extra images — use "+ Add image"</div></div>
</div>
<div id="console"></div>
</div>
</main>
<div class="modal" id="modal">
<div class="modalCard">
<h3 id="modalTitle">Pick a preset</h3>
<div class="sub" id="modalSub"></div>
<div id="modalList"></div>
<div class="modalActions">
<button id="modalCancel">Cancel</button>
</div>
</div>
</div>
<div class="help" id="help">
<span class="x" id="helpX">×</span>
<h3>Code API</h3>
<p>Each <b>layer</b> runs its own JS code as a step in a pipeline:<br>
<code>original → layer1 → layer2 → … → final</code><br>
A layer's <code>prev</code> is the previous layer's output (or the original for the bottom layer).
Write to <code>output</code> — the result is fed as <code>prev</code> to the next layer.
The "strength" number on each layer is a soft mix: <code>lerp(prev, output, strength)</code>.</p>
<h4>Available variables</h4>
<pre>width, height // image size (after scale)
original.pixels // Uint8ClampedArray RGBA of source
original.width, .height
original.getPixel(x,y) // -> {r,g,b,a}
prev // same shape — previous layer's output
// (= original for the bottom layer)
images[i] // additional images you added
// .width .height .pixels .getPixel(x,y)
output // Uint8ClampedArray you write to
// length = width*height*4 (RGBA)</pre>
<h4>Helpers</h4>
<pre>getPixel(x, y) // reads from prev
setPixel(x, y, r, g, b, a=255)
forEach((x, y, p) => { p.r += 5; return p; })
// iterates every pixel of prev, writes back
clamp(v, lo=0, hi=255)
lerp(a, b, t)
PI, TAU, E
sin, cos, tan, asin, acos, atan, atan2,
abs, sqrt, pow, exp, log, floor, ceil, round, min, max,
random, hypot, sign</pre>
<h4>Examples — basics</h4>
<pre>// brightness +5
forEach((x,y,p) => { p.r=clamp(p.r+5); p.g=clamp(p.g+5); p.b=clamp(p.b+5); });
// invert
forEach((x,y,p) => { p.r=255-p.r; p.g=255-p.g; p.b=255-p.b; });
// grayscale
forEach((x,y,p) => {
const g = (p.r * 0.299 + p.g * 0.587 + p.b * 0.114) | 0;
p.r = p.g = p.b = g;
});</pre>
<h4>Examples — pixel coordinates / index</h4>
<pre>// max blue at column x === 50
forEach((x, y, p) => {
if (x === 50) { p.r = 0; p.g = 0; p.b = 255; }
});
// horizontal red line at y === 100, 1px tall
forEach((x, y, p) => {
if (y === 100) { p.r = 255; p.g = 0; p.b = 0; }
});
// every 500th pixel (by ordinal index): green channel * 2
forEach((x, y, p) => {
const i = y * width + x; // ordinal pixel index
if (i % 500 === 0) p.g = clamp(p.g * 2);
});
// vertical stripes every 20 px
forEach((x, y, p) => {
if (x % 20 === 0) { p.r = 255; p.g = 255; p.b = 0; }
});
// checkerboard tint
forEach((x, y, p) => {
if (((x >> 4) + (y >> 4)) & 1) p.b = clamp(p.b + 80);
});</pre>
<h4>Examples — random</h4>
<pre>// pure noise
forEach((x, y, p) => {
p.r = random256(); p.g = random256(); p.b = random256();
});
// salt &amp; pepper: 1% pixels become white, 1% black
forEach((x, y, p) => {
const r = random1();
if (r &lt; 0.01) { p.r = p.g = p.b = 255; }
else if (r &lt; 0.02) { p.r = p.g = p.b = 0; }
});
// random R/B channel swap on half the pixels
forEach((x, y, p) => {
if (randomBoolean()) { const t = p.r; p.r = p.b; p.b = t; }
});
// jittered brightness ±30
forEach((x, y, p) => {
const j = randomInt(-30, 30);
p.r = clamp(p.r + j); p.g = clamp(p.g + j); p.b = clamp(p.b + j);
});
// 30% chance: take pixel from random spot in original
forEach((x, y, p) => {
if (random1() &lt; 0.3) {
const q = original.getPixel(randomInt(0, width-1), randomInt(0, height-1));
p.r = q.r; p.g = q.g; p.b = q.b;
}
});</pre>
<h4>Examples — geometry &amp; math</h4>
<pre>// sine wave horizontal displacement
for (let y = 0; y &lt; height; y++) {
for (let x = 0; x &lt; width; x++) {
const dx = floor(sin(y * 0.05) * 20);
const p = prev.getPixel((x + dx + width) % width, y);
setPixel(x, y, p.r, p.g, p.b, p.a);
}
}
// radial gradient over original (darken edges)
const cx = width / 2, cy = height / 2;
const maxD = hypot(cx, cy);
forEach((x, y, p) => {
const d = hypot(x - cx, y - cy) / maxD; // 0..1
const k = 1 - d;
p.r = (p.r * k) | 0;
p.g = (p.g * k) | 0;
p.b = (p.b * k) | 0;
});</pre>
<h4>Examples — second image</h4>
<pre>// average with images[0] (added via "+ Add image")
const img = images[0];
forEach((x, y, p) => {
const q = img.getPixel(x % img.width, y % img.height);
p.r = (p.r + q.r) &gt;&gt; 1;
p.g = (p.g + q.g) &gt;&gt; 1;
p.b = (p.b + q.b) &gt;&gt; 1;
});
// difference (abs) — highlights what's different
const img = images[0];
forEach((x, y, p) => {
const q = img.getPixel(x % img.width, y % img.height);
p.r = abs(p.r - q.r); p.g = abs(p.g - q.g); p.b = abs(p.b - q.b);
});
// paste images[0] at coordinates (offsetX, offsetY)
const img = images[0];
const offsetX = 50, offsetY = 100;
for (let y = 0; y &lt; img.height; y++) {
for (let x = 0; x &lt; img.width; x++) {
const dx = x + offsetX, dy = y + offsetY;
if (dx &lt; 0 || dx &gt;= width || dy &lt; 0 || dy &gt;= height) continue;
const q = img.getPixel(x, y);
setPixel(dx, dy, q.r, q.g, q.b, q.a);
}
}
// paste with alpha blending (respects q.a as transparency)
const img = images[0];
const offsetX = 50, offsetY = 100;
for (let y = 0; y &lt; img.height; y++) {
for (let x = 0; x &lt; img.width; x++) {
const dx = x + offsetX, dy = y + offsetY;
if (dx &lt; 0 || dx &gt;= width || dy &lt; 0 || dy &gt;= height) continue;
const q = img.getPixel(x, y);
const p = prev.getPixel(dx, dy);
const a = q.a / 255;
setPixel(dx, dy,
p.r * (1 - a) + q.r * a,
p.g * (1 - a) + q.g * a,
p.b * (1 - a) + q.b * a,
255);
}
}
// paste centered on the canvas
const img = images[0];
const offsetX = ((width - img.width) / 2) | 0;
const offsetY = ((height - img.height) / 2) | 0;
for (let y = 0; y &lt; img.height; y++) {
for (let x = 0; x &lt; img.width; x++) {
const q = img.getPixel(x, y);
setPixel(x + offsetX, y + offsetY, q.r, q.g, q.b, q.a);
}
}
// paste with math: add images[0] to original at (offsetX, offsetY)
const img = images[0];
const offsetX = 50, offsetY = 100;
for (let y = 0; y &lt; img.height; y++) {
for (let x = 0; x &lt; img.width; x++) {
const dx = x + offsetX, dy = y + offsetY;
if (dx &lt; 0 || dx &gt;= width || dy &lt; 0 || dy &gt;= height) continue;
const q = img.getPixel(x, y);
const p = prev.getPixel(dx, dy);
setPixel(dx, dy, clamp(p.r + q.r), clamp(p.g + q.g), clamp(p.b + q.b));
}
}</pre>
<h4>Performance tip</h4>
<p>JS per-pixel loops are slow on huge images. Use the <b>scale</b> selector
(e.g. 0.25x) for live editing, then bump to 1x when exporting.</p>
</div>
<script>
"use strict";
// ---------------- State ----------------
const state = {
original: null, // {pixels, width, height} at full res
scaledOriginal: null, // {pixels, width, height} at current scale
scale: 0.25,
layers: [], // [{id, name, code, visible, blend, opacity}]
activeLayerId: null,
extraImages: [], // [{name, full:{...}, scaled:{...}, thumbCanvas}]
autoRun: true,
};
let layerIdSeq = 1;
let imgIdSeq = 1;
const DEFAULT_CODE = `// each layer runs this code.
// 'output' is your RGBA buffer; 'prev' is the previous layer's output (or original).
// see ? for full API.
forEach((x, y, p) => {
p.r = clamp(p.r + 5);
p.g = clamp(p.g + 5);
p.b = clamp(p.b + 5);
});
`;
// ---------------- DOM refs ----------------
const $ = id => document.getElementById(id);
const canvasOut = $("canvasOut");
const canvasOrig = $("canvasOrig");
const canvasWrap = $("canvasWrap");
const emptyHint = $("emptyHint");
const canvasInfo = $("canvasInfo");
const layersPanel = $("layersPanel");
const editor = $("editor");
const highlightEl = $("highlight");
const editorLabel = $("editorLabel");
const consoleEl = $("console");
const statusEl = $("status");
const imagesList = $("imagesList");
const varsPanel = $("varsPanel");
const ctxOut = canvasOut.getContext("2d");
const ctxOrig = canvasOrig.getContext("2d");
// ---------------- Logging ----------------
function log(msg, cls="") {
const line = document.createElement("div");
if (cls) line.className = cls;
line.textContent = msg;
consoleEl.appendChild(line);
consoleEl.scrollTop = consoleEl.scrollHeight;
if (consoleEl.children.length > 200) consoleEl.removeChild(consoleEl.firstChild);
}
function setStatus(text, cls="") {
statusEl.textContent = text;
statusEl.className = "status " + cls;
}
// ---------------- Image loading ----------------
function loadImageFile(file) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
const c = document.createElement("canvas");
c.width = img.width; c.height = img.height;
const cx = c.getContext("2d");
cx.drawImage(img, 0, 0);
const data = cx.getImageData(0, 0, img.width, img.height);
URL.revokeObjectURL(url);
resolve({ pixels: data.data, width: img.width, height: img.height });
};
img.onerror = e => { URL.revokeObjectURL(url); reject(e); };
img.src = url;
});
}
function rescale(srcImg, scale) {
const w = Math.max(1, Math.round(srcImg.width * scale));
const h = Math.max(1, Math.round(srcImg.height * scale));
const c = document.createElement("canvas");
c.width = w; c.height = h;
const cx = c.getContext("2d");
// Put original into a temp canvas
const src = document.createElement("canvas");
src.width = srcImg.width; src.height = srcImg.height;
const scx = src.getContext("2d");
const id = scx.createImageData(srcImg.width, srcImg.height);
id.data.set(srcImg.pixels);
scx.putImageData(id, 0, 0);
cx.imageSmoothingEnabled = scale < 1 ? true : false;
cx.drawImage(src, 0, 0, w, h);
const dat = cx.getImageData(0, 0, w, h);
return { pixels: dat.data, width: w, height: h };
}
// ---------------- Layers ----------------
function newLayer(name="layer", code=DEFAULT_CODE) {
return {
id: layerIdSeq++,
name,
code,
visible: true,
blend: "normal",
opacity: 1.0,
};
}
function addLayerAndSelect() {
pushUndo();
const l = newLayer("layer " + layerIdSeq);
state.layers.push(l);
state.activeLayerId = l.id;
renderLayers();
loadEditorFromActive();
scheduleRun();
}
function deleteLayer(id) {
if (state.layers.length <= 1) { log("can't delete last layer", "err"); return; }
pushUndo();
const idx = state.layers.findIndex(l => l.id === id);
state.layers.splice(idx, 1);
if (state.activeLayerId === id) {
state.activeLayerId = state.layers[Math.max(0, idx-1)].id;
}
renderLayers();
loadEditorFromActive();
scheduleRun();
}
function moveLayer(id, dir) {
const i = state.layers.findIndex(l => l.id === id);
const j = i + dir;
if (j < 0 || j >= state.layers.length) return;
pushUndo();
const tmp = state.layers[i];
state.layers[i] = state.layers[j];
state.layers[j] = tmp;
renderLayers();
scheduleRun();
}
function getActiveLayer() {
return state.layers.find(l => l.id === state.activeLayerId);
}
function loadEditorFromActive() {
const l = getActiveLayer();
if (!l) { editor.value = ""; editorLabel.textContent = ""; updateHighlight(); return; }
editor.value = l.code;
editorLabel.textContent = "— " + l.name;
updateHighlight();
}
function renderLayers() {
layersPanel.innerHTML = "";
// top layer first in UI (composite order: bottom→top, so render reversed)
for (let i = state.layers.length - 1; i >= 0; i--) {
const l = state.layers[i];
const row = document.createElement("div");
row.className = "layer" + (l.id === state.activeLayerId ? " active" : "");
row.onclick = () => {
state.activeLayerId = l.id;
renderLayers();
loadEditorFromActive();
};
const top = document.createElement("div");
top.className = "topRow";
const vis = document.createElement("div");
vis.className = "vis " + (l.visible ? "on" : "");
vis.textContent = l.visible ? "●" : "○";
vis.title = "toggle visibility";
vis.onclick = (e) => { e.stopPropagation(); pushUndo(); l.visible = !l.visible; renderLayers(); scheduleRun(); };
top.appendChild(vis);
const startRename = (e) => {
if (e) e.stopPropagation();
const inp = document.createElement("input");
inp.value = l.name;
inp.onblur = () => {
const newName = (inp.value || "").trim() || l.name;
if (newName !== l.name) pushUndo();
l.name = newName;
renderLayers();
loadEditorFromActive();
};
inp.onkeydown = (ev) => {
if (ev.key === "Enter") inp.blur();
else if (ev.key === "Escape") { inp.value = l.name; inp.blur(); }
};
name.innerHTML = ""; name.appendChild(inp); inp.focus(); inp.select();
};
const name = document.createElement("div");
name.className = "name";
name.textContent = l.name;
name.title = "double-click to rename";
name.ondblclick = startRename;
top.appendChild(name);
const acts = document.createElement("div");
acts.className = "actions";
const ren = document.createElement("button");
ren.textContent = "✎"; ren.title = "rename layer";
ren.onclick = e => { e.stopPropagation(); startRename(); };
const up = document.createElement("button");
up.textContent = "▲"; up.title = "move up";
up.onclick = e => { e.stopPropagation(); moveLayer(l.id, +1); };
const dn = document.createElement("button");
dn.textContent = "▼"; dn.title = "move down";
dn.onclick = e => { e.stopPropagation(); moveLayer(l.id, -1); };
const dl = document.createElement("button");
dl.textContent = "✕"; dl.title = "delete";
dl.className = "danger";
dl.onclick = e => { e.stopPropagation(); deleteLayer(l.id); };
acts.appendChild(ren); acts.appendChild(up); acts.appendChild(dn); acts.appendChild(dl);
top.appendChild(acts);
row.appendChild(top);
const opWrap = document.createElement("div");
opWrap.className = "opacityWrap";
opWrap.title = "strength: lerp(prev, this layer's output, strength)";
opWrap.onclick = e => e.stopPropagation();
const opLabel = document.createElement("span");
opLabel.className = "opLabel";
opLabel.textContent = "strength";
const op = document.createElement("input");
op.type = "range"; op.min = "0"; op.max = "1"; op.step = "0.01";
op.value = l.opacity; op.className = "opacitySlider";
const opVal = document.createElement("span");
opVal.className = "opacityVal";
opVal.textContent = Number(l.opacity).toFixed(2);
op.oninput = () => {
beginBurst();
l.opacity = Math.max(0, Math.min(1, parseFloat(op.value)||0));
opVal.textContent = l.opacity.toFixed(2);
scheduleRun();
};
opWrap.appendChild(opLabel);
opWrap.appendChild(op);
opWrap.appendChild(opVal);
row.appendChild(opWrap);
layersPanel.appendChild(row);
}
}
// ---------------- Sandbox API ----------------
function buildContext(prevPixels, w, h, extraImages) {
const out = new Uint8ClampedArray(w * h * 4);
// start with prev as the base — user can read prev OR overwrite output freely
out.set(prevPixels);
function makeImageObj(px, ww, hh) {
return {
pixels: px, width: ww, height: hh,
getPixel(x, y) {
x = x|0; y = y|0;
if (x<0||x>=ww||y<0||y>=hh) return {r:0,g:0,b:0,a:0};
const i = (y*ww + x) * 4;
return { r:px[i], g:px[i+1], b:px[i+2], a:px[i+3] };
}
};
}
const original = makeImageObj(state.scaledOriginal.pixels, w, h);
const prev = makeImageObj(prevPixels, w, h);
function getPixel(x, y) { return prev.getPixel(x, y); }
function setPixel(x, y, r, g, b, a) {
x = x|0; y = y|0;
if (x<0||x>=w||y<0||y>=h) return;
const i = (y*w + x) * 4;
if (typeof r === "object" && r !== null) {
// setPixel(x,y,{r,g,b,a})
out[i] = r.r|0;
out[i+1] = r.g|0;
out[i+2] = r.b|0;
out[i+3] = (r.a==null?255:r.a)|0;
} else {
out[i] = r|0;
out[i+1] = g|0;
out[i+2] = b|0;
out[i+3] = (a==null?255:a)|0;
}
}
function forEach(fn) {
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y*w + x) * 4;
const p = { r: prevPixels[i], g: prevPixels[i+1], b: prevPixels[i+2], a: prevPixels[i+3] };
const r = fn(x, y, p);
const q = (r === undefined) ? p : r;
out[i] = q.r|0;
out[i+1] = q.g|0;
out[i+2] = q.b|0;
out[i+3] = (q.a==null?255:q.a)|0;
}
}
}
function clamp(v, lo=0, hi=255) { return v < lo ? lo : v > hi ? hi : v; }
function lerp(a, b, t) { return a + (b - a) * t; }
function random1() { return Math.random(); }
function random256() { return Math.floor(Math.random() * 256); }
function randomBoolean() { return Math.random() < 0.5; }
function randomRange(lo, hi) { return lo + Math.random() * (hi - lo); }
function randomInt(lo, hi) { return Math.floor(lo + Math.random() * (hi - lo + 1)); }
const images = extraImages.map(im => makeImageObj(im.scaled.pixels, im.scaled.width, im.scaled.height));
return {
width: w, height: h,
output: out,
original, prev, images,
getPixel, setPixel, forEach,
clamp, lerp,
random1, random256, randomBoolean, randomRange, randomInt,
PI: Math.PI, TAU: Math.PI*2, E: Math.E,
sin: Math.sin, cos: Math.cos, tan: Math.tan,
asin: Math.asin, acos: Math.acos, atan: Math.atan, atan2: Math.atan2,
abs: Math.abs, sqrt: Math.sqrt, pow: Math.pow, exp: Math.exp, log: Math.log,
floor: Math.floor, ceil: Math.ceil, round: Math.round,
min: Math.min, max: Math.max, hypot: Math.hypot, sign: Math.sign,
random: Math.random,
Math,
log: (...a) => log("[layer] " + a.map(x => typeof x === "object" ? JSON.stringify(x) : String(x)).join(" "), "info"),
};
}
function runLayerCode(code, prevPixels, w, h) {
const ctx = buildContext(prevPixels, w, h, state.extraImages);
const keys = Object.keys(ctx);
const vals = keys.map(k => ctx[k]);
// wrap user code so they can use `return` if they want
const fn = new Function(...keys, '"use strict";\n' + code);
fn.apply(null, vals);
return ctx.output;
}
// ---------------- Compositing ----------------
// Pure pipeline: lerp(prev, layerOut, strength). strength=1 → use layer output as-is.
function compositePixels(prev, layerOut, strength, w, h) {
if (strength >= 1) return layerOut;
if (strength <= 0) return prev;
const out = new Uint8ClampedArray(w * h * 4);
const t = strength, it = 1 - strength;
for (let i = 0; i < prev.length; i++) {
out[i] = prev[i] * it + layerOut[i] * t;
}
return out;
}
// ---------------- Render ----------------
let runScheduled = false;
function scheduleRun() {
if (!state.autoRun) return;
if (runScheduled) return;
runScheduled = true;
requestAnimationFrame(() => { runScheduled = false; runAllLayers(); });
}
function runAllLayers() {
if (!state.scaledOriginal) return;
const t0 = performance.now();
const w = state.scaledOriginal.width;
const h = state.scaledOriginal.height;
// sync editor → active layer code
const al = getActiveLayer();
if (al) al.code = editor.value;
// pure pipeline: each layer's input = previous layer's output (or original for first)
let current = new Uint8ClampedArray(state.scaledOriginal.pixels);
for (let i = 0; i < state.layers.length; i++) {
const layer = state.layers[i];
if (!layer.visible) continue;
let layerOut;
try {
layerOut = runLayerCode(layer.code, current, w, h);
} catch (err) {
log(`[${layer.name}] ERROR: ${err.message}`, "err");
continue;
}
current = compositePixels(current, layerOut, layer.opacity, w, h);
}
const composite = current;
// write to canvas
canvasOut.width = w;
canvasOut.height = h;
const id = ctxOut.createImageData(w, h);
id.data.set(composite);
ctxOut.putImageData(id, 0, 0);
// also draw original
canvasOrig.width = w;
canvasOrig.height = h;
const id2 = ctxOrig.createImageData(w, h);
id2.data.set(state.scaledOriginal.pixels);
ctxOrig.putImageData(id2, 0, 0);
fitCanvasToPane();
const ms = (performance.now() - t0).toFixed(1);
setStatus(`${state.original.width}×${state.original.height} • running at ${w}×${h}${ms}ms`, "ok");
setBaseCanvasInfo(`${w}×${h} (scale ${state.scale}x) • ${ms}ms • hover for pixel info • click&hold for original`);
}
function fitCanvasToPane() {
// Make canvas auto fit
const pane = $("canvasPane");
const W = pane.clientWidth - 24, H = pane.clientHeight - 24;
const cw = canvasOut.width, ch = canvasOut.height;
if (!cw || !ch) return;
const r = Math.min(W / cw, H / ch);
const dw = Math.max(1, Math.floor(cw * r));
const dh = Math.max(1, Math.floor(ch * r));
canvasOut.style.width = dw + "px";
canvasOut.style.height = dh + "px";
canvasOrig.style.width = dw + "px";
canvasOrig.style.height = dh + "px";
canvasWrap.style.width = dw + "px";
canvasWrap.style.height = dh + "px";
}
// ---------------- Canvas: hover = pixel info, click&hold = show original ----------------
let hoverPixel = null; // {x, y} in image-space, or null
let baseCanvasInfo = ""; // size/ms info to restore when not hovering
function setBaseCanvasInfo(text) {
baseCanvasInfo = text;
if (!hoverPixel) canvasInfo.textContent = text;
}
function updatePixelInfo(e) {
if (!canvasOut.width || !canvasOut.height) return;
const rect = canvasOut.getBoundingClientRect();
const x = Math.floor((e.clientX - rect.left) / rect.width * canvasOut.width);
const y = Math.floor((e.clientY - rect.top) / rect.height * canvasOut.height);
if (x < 0 || x >= canvasOut.width || y < 0 || y >= canvasOut.height) {
hoverPixel = null;
canvasInfo.textContent = baseCanvasInfo;
canvasInfo.style.borderLeft = "";
return;
}
hoverPixel = { x, y };
// pick from whichever canvas is currently visible
const showingOrig = canvasWrap.classList.contains("showOrig");
const src = showingOrig ? ctxOrig : ctxOut;
const d = src.getImageData(x, y, 1, 1).data;
const r = d[0], g = d[1], b = d[2], a = d[3];
const hex = "#" + ((r << 16) | (g << 8) | b).toString(16).padStart(6, "0");
const tag = showingOrig ? "ORIG" : "OUT";
canvasInfo.innerHTML =
`<span style="color:#${(r<<16|g<<8|b).toString(16).padStart(6,'0')};` +
`text-shadow:0 0 2px #000">■</span> ` +
`[${tag}] (${x}, ${y}) rgba(${r}, ${g}, ${b}, ${a}) ${hex}`;
}
canvasWrap.addEventListener("mousemove", updatePixelInfo);
canvasWrap.addEventListener("mouseleave", () => {
hoverPixel = null;
canvasWrap.classList.remove("showOrig");
canvasInfo.textContent = baseCanvasInfo;
});
// click-and-hold to view original
canvasWrap.addEventListener("mousedown", (e) => {
if (e.button !== 0) return;
canvasWrap.classList.add("showOrig");
updatePixelInfo(e);
});
window.addEventListener("mouseup", () => {
if (canvasWrap.classList.contains("showOrig")) {
canvasWrap.classList.remove("showOrig");
}
});
// ---------------- Export ----------------
async function exportPng() {
if (!state.original) { log("no image to export", "err"); return; }
log("rendering at full resolution for export…", "info");
// Render at scale=1 for export
const savedScaled = state.scaledOriginal;
state.scaledOriginal = state.original;
// also rescale extra images to full
const savedExtras = state.extraImages.map(im => im.scaled);
state.extraImages.forEach(im => im.scaled = im.full);
let current = new Uint8ClampedArray(state.original.pixels);
const w = state.original.width, h = state.original.height;
for (const layer of state.layers) {
if (!layer.visible) continue;
let out;
try { out = runLayerCode(layer.code, current, w, h); }
catch (err) { log(`[${layer.name}] export ERROR: ${err.message}`, "err"); continue; }
current = compositePixels(current, out, layer.opacity, w, h);
}
const composite = current;
// restore preview-state
state.scaledOriginal = savedScaled;
state.extraImages.forEach((im, i) => im.scaled = savedExtras[i]);
// make canvas, encode
const c = document.createElement("canvas");
c.width = w; c.height = h;
const cx = c.getContext("2d");
const id = cx.createImageData(w, h);
id.data.set(composite);
cx.putImageData(id, 0, 0);
c.toBlob((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "trakodag_export.png";
a.click();
URL.revokeObjectURL(url);
log("exported.", "ok");
}, "image/png");
}
// ---------------- Extra images UI ----------------
function renderExtraImages() {
imagesList.innerHTML = "";
if (state.extraImages.length === 0) {
const e = document.createElement("div");
e.className = "imgEmpty";
e.textContent = 'no extra images — use "+ Add image"';
imagesList.appendChild(e);
return;
}
state.extraImages.forEach((im, idx) => {
const wrap = document.createElement("div");
wrap.className = "imgThumb";
wrap.title = `${im.name} — images[${idx}] (${im.full.width}×${im.full.height})`;
const c = document.createElement("canvas");
c.width = im.full.width; c.height = im.full.height;
const cx = c.getContext("2d");
const id = cx.createImageData(im.full.width, im.full.height);
id.data.set(im.full.pixels);
cx.putImageData(id, 0, 0);
wrap.appendChild(c);
const lab = document.createElement("div");
lab.className = "label";
lab.textContent = `[${idx}]`;
wrap.appendChild(lab);
const del = document.createElement("button");
del.className = "del"; del.textContent = "×";
del.title = "remove";
del.onclick = () => {
state.extraImages.splice(idx, 1);
renderExtraImages();
scheduleRun();
};
wrap.appendChild(del);
imagesList.appendChild(wrap);
});
}
// ---------------- Syntax highlighting ----------------
const KEYWORDS = new Set([
"let","const","var","if","else","for","while","do","function","return",
"break","continue","switch","case","default","true","false","null","undefined",
"new","typeof","instanceof","in","of","try","catch","throw","finally","this"
]);
// API names (highlighted as built-ins)
const API_NAMES = new Set([
"width","height","output","original","prev","images","pixels",
"getPixel","setPixel","forEach","clamp","lerp","log",
"PI","TAU","E","Math",
"sin","cos","tan","asin","acos","atan","atan2",
"abs","sqrt","pow","exp","floor","ceil","round","min","max",
"random","random1","random256","randomBoolean","randomRange","randomInt",
"hypot","sign"
]);
function escHtml(s) {
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function highlightCode(code) {
let out = "";
let i = 0;
const n = code.length;
while (i < n) {
const ch = code[i];
// line comment
if (ch === "/" && code[i+1] === "/") {
let j = code.indexOf("\n", i);
if (j === -1) j = n;
out += `<span class="tk-cm">${escHtml(code.slice(i, j))}</span>`;
i = j;
continue;
}
// block comment
if (ch === "/" && code[i+1] === "*") {
let j = code.indexOf("*/", i + 2);
j = (j === -1) ? n : j + 2;
out += `<span class="tk-cm">${escHtml(code.slice(i, j))}</span>`;
i = j;
continue;
}
// strings
if (ch === '"' || ch === "'" || ch === "`") {
const q = ch;
let j = i + 1;
while (j < n) {
if (code[j] === "\\") { j += 2; continue; }
if (code[j] === q) { j++; break; }
j++;
}
out += `<span class="tk-str">${escHtml(code.slice(i, j))}</span>`;
i = j;
continue;
}
// numbers
if ((ch >= "0" && ch <= "9") || (ch === "." && code[i+1] >= "0" && code[i+1] <= "9")) {
const m = code.slice(i).match(/^(0[xX][0-9a-fA-F]+|[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?)/);
if (m) {
out += `<span class="tk-num">${escHtml(m[0])}</span>`;
i += m[0].length;
continue;
}
}
// identifiers / keywords / api
if (/[A-Za-z_$]/.test(ch)) {
const m = code.slice(i).match(/^[A-Za-z_$][A-Za-z_$0-9]*/);
if (m) {
const w = m[0];
let cls = "tk-id";
if (KEYWORDS.has(w)) cls = "tk-kw";
else if (API_NAMES.has(w)) cls = "tk-api";
else if (code[i + w.length] === "(") cls = "tk-fn";
out += `<span class="${cls}">${escHtml(w)}</span>`;
i += w.length;
continue;
}
}
// operators / punctuation
if ("+-*/%=<>!&|^~?:".indexOf(ch) >= 0) {
out += `<span class="tk-op">${escHtml(ch)}</span>`;
i++;
continue;
}
// whitespace / brackets / commas / etc.
out += escHtml(ch);
i++;
}
// trailing newline padding so the pre matches textarea height when caret is on last (empty) line
return out + "\n";
}
function updateHighlight() {
highlightEl.innerHTML = highlightCode(editor.value);
highlightEl.scrollTop = editor.scrollTop;
highlightEl.scrollLeft = editor.scrollLeft;
}
// ---------------- Vars / API reference panel ----------------
const VARS_REF = [
{ group: "Dimensions (current run resolution)", items: [
["width", "image width in pixels"],
["height", "image height in pixels"],
]},
{ group: "Pixel sources (read)", items: [
["original", "source image — { pixels, width, height, getPixel(x,y) }"],
["original.pixels", "Uint8ClampedArray RGBA (length = w*h*4)"],
["original.getPixel(x,y)", "→ {r,g,b,a} from the original image"],
["prev", "previous layer's output (or original for first layer)"],
["prev.pixels", "Uint8ClampedArray RGBA from previous layer"],
["prev.getPixel(x,y)","→ {r,g,b,a} from previous layer's output"],
["images", "extra images — array, each: {pixels, width, height, getPixel}"],
["images[0]", "first extra image (added with '+ Add image')"],
]},
{ group: "Output (write)", items: [
["output", "Uint8ClampedArray RGBA you write to (this layer's result)"],
["setPixel(x,y,r,g,b,a)", "write a pixel — a defaults to 255"],
["setPixel(x,y,p)", "write a pixel object {r,g,b,a}"],
["getPixel(x,y)", "shortcut for prev.getPixel(x,y)"],
["forEach((x,y,p)=>{…})", "iterate every pixel of prev; mutate p or return a new one"],
]},
{ group: "Math helpers", items: [
["clamp(v, lo=0, hi=255)", "clamp value to a range"],
["lerp(a, b, t)", "linear interpolation a→b by t∈[0,1]"],
]},
{ group: "Math constants", items: [
["PI", "Math.PI"],
["TAU", "Math.PI * 2"],
["E", "Math.E"],
]},
{ group: "Random", items: [
["random1()", "→ float in [0, 1)"],
["random256()", "→ int in [0, 255]"],
["randomBoolean()", "→ true or false (50/50)"],
["randomRange(lo,hi)", "→ float in [lo, hi)"],
["randomInt(lo,hi)", "→ int in [lo, hi] (inclusive)"],
["random()", "alias for Math.random() — same as random1()"],
]},
{ group: "Math functions", items: [
["sin","Math.sin"], ["cos","Math.cos"], ["tan","Math.tan"],
["asin","Math.asin"], ["acos","Math.acos"], ["atan","Math.atan"],
["atan2","Math.atan2(y,x)"],
["abs","Math.abs"], ["sqrt","Math.sqrt"], ["pow","Math.pow(b,e)"],
["exp","Math.exp"], ["floor","Math.floor"], ["ceil","Math.ceil"],
["round","Math.round"], ["min","Math.min"], ["max","Math.max"],
["random","Math.random() in [0,1)"], ["hypot","Math.hypot"],
["sign","Math.sign"], ["Math","full Math object"],
]},
{ group: "Logging", items: [
["log(...)", "print to the console pane below"],
]},
];
function renderVarsPanel() {
varsPanel.innerHTML = "";
for (const g of VARS_REF) {
const wrap = document.createElement("div");
wrap.className = "varsGroup";
const h = document.createElement("h5");
h.textContent = g.group;
wrap.appendChild(h);
const list = document.createElement("div");
list.className = "varsList";
for (const [name, desc] of g.items) {
const chip = document.createElement("span");
chip.className = "varChip";
chip.title = "click to insert at cursor";
const code = document.createElement("span");
code.textContent = name;
chip.appendChild(code);
if (desc) {
const d = document.createElement("span");
d.className = "desc";
d.textContent = "— " + desc;
chip.appendChild(d);
}
chip.onclick = () => insertAtCursor(name);
list.appendChild(chip);
}
wrap.appendChild(list);
varsPanel.appendChild(wrap);
}
}
function insertAtCursor(text) {
editor.focus();
const s = editor.selectionStart, e = editor.selectionEnd;
const v = editor.value;
editor.value = v.slice(0, s) + text + v.slice(e);
editor.selectionStart = editor.selectionEnd = s + text.length;
// trigger input handler
editor.dispatchEvent(new Event("input"));
}
// ---------------- Undo / Redo ----------------
const history = {
undo: [],
redo: [],
max: 80,
// burst-edit tracking: while user types, only one snapshot per burst
bursting: false,
burstTimer: null,
};
function snapshotState() {
// sync editor → active layer code first
const al = getActiveLayer();
if (al) al.code = editor.value;
return JSON.stringify({
layers: state.layers.map(l => ({
id: l.id, name: l.name, code: l.code,
visible: l.visible, opacity: l.opacity,
})),
activeLayerId: state.activeLayerId,
});
}
function applyState(snap) {
const obj = JSON.parse(snap);
state.layers = obj.layers.map(l => ({
id: l.id, name: l.name, code: l.code,
visible: l.visible, opacity: l.opacity,
blend: "normal",
}));
// keep id sequence past max id
const maxId = state.layers.reduce((m, l) => Math.max(m, l.id), 0);
if (layerIdSeq <= maxId) layerIdSeq = maxId + 1;
state.activeLayerId = obj.activeLayerId;
if (!state.layers.find(l => l.id === state.activeLayerId)) {
state.activeLayerId = state.layers[0]?.id;
}
renderLayers();
loadEditorFromActive();
scheduleRun();
}
function pushUndo() {
const snap = snapshotState();
if (history.undo.length > 0 && history.undo[history.undo.length - 1] === snap) return;
history.undo.push(snap);
if (history.undo.length > history.max) history.undo.shift();
history.redo = [];
updateUndoButtons();
}
// for burst edits (typing in editor, dragging slider): push only the FIRST
// pre-edit snapshot, then suppress further pushes until idle
function beginBurst() {
if (!history.bursting) {
pushUndo();
history.bursting = true;
}
clearTimeout(history.burstTimer);
history.burstTimer = setTimeout(() => { history.bursting = false; }, 700);
}
function doUndo() {
if (history.undo.length === 0) return;
history.bursting = false;
clearTimeout(history.burstTimer);
const cur = snapshotState();
const prev = history.undo.pop();
history.redo.push(cur);
applyState(prev);
updateUndoButtons();
}
function doRedo() {
if (history.redo.length === 0) return;
const cur = snapshotState();
const next = history.redo.pop();
history.undo.push(cur);
applyState(next);
updateUndoButtons();
}
function updateUndoButtons() {
$("undoBtn").disabled = history.undo.length === 0;
$("redoBtn").disabled = history.redo.length === 0;
}
// ---------------- Modal ----------------
function showPresetModal(title, subtitle, items, onPick) {
$("modalTitle").textContent = title;
$("modalSub").textContent = subtitle || "";
const list = $("modalList");
list.innerHTML = "";
items.forEach((it, idx) => {
const div = document.createElement("div");
div.className = "presetItem";
const h = document.createElement("h4");
h.textContent = it.name || `Preset #${idx+1}`;
const p = document.createElement("p");
p.textContent = it.description || "";
div.appendChild(h); div.appendChild(p);
div.onclick = () => { closeModal(); onPick(it, idx); };
list.appendChild(div);
});
$("modal").classList.add("open");
}
function closeModal() { $("modal").classList.remove("open"); }
// ---------------- Project save / load ----------------
// A "project" stores only layers + code (no image data).
const PROJECT_FORMAT = "trakodag.web";
const PROJECT_VERSION = 1;
function saveProject() {
// sync current editor buffer into active layer
const al = getActiveLayer();
if (al) al.code = editor.value;
const proj = {
format: PROJECT_FORMAT,
version: PROJECT_VERSION,
savedAt: new Date().toISOString(),
layers: state.layers.map(l => ({
name: l.name,
code: l.code,
visible: l.visible,
opacity: l.opacity,
})),
};
const json = JSON.stringify(proj, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
// build a nice filename
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
a.href = url;
a.download = `trakodag_${ts}.json`;
a.click();
URL.revokeObjectURL(url);
log(`saved project (${proj.layers.length} layer${proj.layers.length===1?"":"s"})`, "ok");
}
function applyProject(proj) {
if (!Array.isArray(proj.layers) || proj.layers.length === 0) {
throw new Error("project has no layers");
}
pushUndo(); // allow reverting the load
state.layers = proj.layers.map(l => ({
id: layerIdSeq++,
name: typeof l.name === "string" ? l.name : "layer",
code: typeof l.code === "string" ? l.code : DEFAULT_CODE,
visible: l.visible !== false,
blend: "normal",
opacity: typeof l.opacity === "number"
? Math.max(0, Math.min(1, l.opacity))
: 1.0,
}));
state.activeLayerId = state.layers[0].id;
renderLayers();
loadEditorFromActive();
scheduleRun();
}
function loadProject(proj) {
if (!proj || typeof proj !== "object") {
throw new Error("not a trakodag file");
}
// single project
if (proj.format === PROJECT_FORMAT) {
applyProject(proj);
return;
}
// presets bundle
if (proj.format === "trakodag.web.presets" && Array.isArray(proj.presets)) {
showPresetModal(
proj.name || "Pick a preset",
proj.description || `${proj.presets.length} presets — click one to load it`,
proj.presets,
(preset) => {
applyProject(preset);
log(`loaded preset: ${preset.name || "(unnamed)"}`, "ok");
}
);
return;
}
throw new Error("not a trakodag project file");
}
// ---------------- Wire up events ----------------
$("fileMain").onchange = async (e) => {
const f = e.target.files[0]; if (!f) return;
setStatus("loading…");
try {
state.original = await loadImageFile(f);
state.scaledOriginal = rescale(state.original, state.scale);
if (state.layers.length === 0) {
state.layers.push(newLayer("base", DEFAULT_CODE));
state.activeLayerId = state.layers[0].id;
}
emptyHint.style.display = "none";
canvasWrap.style.display = "block";
renderLayers();
loadEditorFromActive();
runAllLayers();
log(`loaded ${f.name} (${state.original.width}×${state.original.height})`, "ok");
} catch (err) {
log("load failed: " + err, "err");
}
e.target.value = "";
};
$("fileExtra").onchange = async (e) => {
for (const f of e.target.files) {
try {
const full = await loadImageFile(f);
const scaled = state.scaledOriginal
? rescale(full, state.scale)
: full;
state.extraImages.push({ name: f.name, full, scaled });
log(`+ image ${f.name} -> images[${state.extraImages.length - 1}]`, "ok");
} catch (err) { log("add image failed: " + err, "err"); }
}
renderExtraImages();
scheduleRun();
e.target.value = "";
};
$("addLayer").onclick = () => addLayerAndSelect();
$("runBtn").onclick = () => runAllLayers();
$("autoRun").onchange = (e) => { state.autoRun = e.target.checked; };
$("scaleSel").onchange = (e) => {
state.scale = parseFloat(e.target.value);
if (state.original) {
state.scaledOriginal = rescale(state.original, state.scale);
state.extraImages.forEach(im => { im.scaled = rescale(im.full, state.scale); });
runAllLayers();
}
};
// ---------------- Themes ----------------
const THEMES = {
blue: { accent: "#6cf", accent2: "#ffb86c" },
purpleSea: { accent: "#c4a5ff", accent2: "#14d6c0" },
};
function applyTheme(name) {
const t = THEMES[name] || THEMES.blue;
document.documentElement.style.setProperty("--accent", t.accent);
document.documentElement.style.setProperty("--accent2", t.accent2);
try { localStorage.setItem("trakodag.theme", name); } catch (_) {}
}
$("themeSel").onchange = (e) => applyTheme(e.target.value);
// load saved theme
(function initTheme() {
let name = "blue";
try { name = localStorage.getItem("trakodag.theme") || "blue"; } catch (_) {}
if (!THEMES[name]) name = "blue";
$("themeSel").value = name;
applyTheme(name);
})();
$("exportBtn").onclick = exportPng;
$("saveProjectBtn").onclick = saveProject;
$("loadProjectFile").onchange = async (e) => {
const f = e.target.files[0];
if (!f) return;
try {
const text = await f.text();
loadProject(JSON.parse(text));
log(`loaded project: ${f.name}`, "ok");
} catch (err) {
log("load project failed: " + err.message, "err");
}
e.target.value = "";
};
$("resetCode").onclick = () => {
const l = getActiveLayer();
if (!l) return;
if (!confirm("Reset this layer's code to the default template?")) return;
pushUndo();
l.code = DEFAULT_CODE;
loadEditorFromActive();
scheduleRun();
};
$("helpBtn").onclick = () => $("help").classList.toggle("open");
$("helpX").onclick = () => $("help").classList.remove("open");
// editor: tab insertion + auto run on input (debounced) + syntax highlight
let editTimer = null;
editor.addEventListener("input", () => {
beginBurst();
const l = getActiveLayer();
if (l) l.code = editor.value;
updateHighlight();
if (state.autoRun) {
clearTimeout(editTimer);
editTimer = setTimeout(runAllLayers, 250);
}
});
editor.addEventListener("scroll", () => {
highlightEl.scrollTop = editor.scrollTop;
highlightEl.scrollLeft = editor.scrollLeft;
});
editor.addEventListener("keydown", (e) => {
if (e.key === "Tab") {
e.preventDefault();
const s = editor.selectionStart, t = editor.selectionEnd;
editor.value = editor.value.slice(0,s) + " " + editor.value.slice(t);
editor.selectionStart = editor.selectionEnd = s + 2;
editor.dispatchEvent(new Event("input"));
} else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
runAllLayers();
}
});
$("varsBtn").onclick = () => varsPanel.classList.toggle("open");
// undo / redo
$("undoBtn").onclick = doUndo;
$("redoBtn").onclick = doRedo;
$("modalCancel").onclick = closeModal;
$("modal").onclick = (e) => { if (e.target.id === "modal") closeModal(); };
// global shortcuts
window.addEventListener("keydown", (e) => {
// ignore plain typing in inputs (but Ctrl+Z / Ctrl+Y still work everywhere)
const meta = e.ctrlKey || e.metaKey;
if (meta && e.key.toLowerCase() === "z" && !e.shiftKey) {
e.preventDefault(); doUndo();
} else if (meta && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) {
e.preventDefault(); doRedo();
} else if (e.key === "Escape") {
closeModal();
$("help").classList.remove("open");
}
});
window.addEventListener("resize", fitCanvasToPane);
// ---------------- Init ----------------
state.layers.push(newLayer("base", DEFAULT_CODE));
state.activeLayerId = state.layers[0].id;
renderLayers();
renderVarsPanel();
loadEditorFromActive();
// syntax-highlight all <pre> code examples inside the help panel
document.querySelectorAll("#help pre").forEach(pre => {
pre.innerHTML = highlightCode(pre.textContent).replace(/\n$/, "");
});
updateUndoButtons();
log("trakodag.web ready. Load an image to begin.", "info");
log("Hint: Ctrl/Cmd + Enter to run, Ctrl/Cmd+Z to undo, ? for the API, ⓘ Vars for special variables.", "info");
</script>
</body>
</html>