Created
July 11, 2026 09:43
-
-
Save wzjoriv/89155bd909035c5259d9784c222bd0ca to your computer and use it in GitHub Desktop.
Calligraphy Stroke Recorder
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Calligraphy Stroke Recorder</title> | |
| <style> | |
| :root{ | |
| --paper:#f6f1e7; | |
| --ink:#2b2420; | |
| --ink-soft:#5a4f45; | |
| --line:#d8cdb8; | |
| --accent:#7a3b2e; | |
| --accent-soft:#c9a98a; | |
| } | |
| *{box-sizing:border-box;} | |
| body{ | |
| margin:0; | |
| font-family: 'Iowan Old Style','Palatino Linotype',Georgia,serif; | |
| background:#e9e2d2; | |
| color:var(--ink); | |
| display:flex; | |
| flex-direction:column; | |
| align-items:center; | |
| padding:14px 6px 20px; | |
| } | |
| h1{ | |
| font-weight:600; | |
| letter-spacing:.03em; | |
| margin:0 0 4px; | |
| font-size:1.5rem; | |
| } | |
| p.sub{ | |
| margin:0 0 20px; | |
| color:var(--ink-soft); | |
| font-size:.92rem; | |
| } | |
| .wrap{ | |
| display:flex; | |
| gap:20px; | |
| width:100%; | |
| max-width:1600px; | |
| margin:0 auto; | |
| flex-wrap:nowrap; | |
| align-items:center; | |
| justify-content:center; | |
| } | |
| .panel{ | |
| background:#fffdf8; | |
| border:1px solid var(--line); | |
| border-radius:6px; | |
| padding:16px 18px; | |
| min-width:220px; | |
| flex-shrink:0; | |
| } | |
| .panel h2{ | |
| font-size:.78rem; | |
| text-transform:uppercase; | |
| letter-spacing:.12em; | |
| color:var(--ink-soft); | |
| margin:0 0 12px; | |
| border-bottom:1px solid var(--line); | |
| padding-bottom:6px; | |
| } | |
| label{ | |
| display:block; | |
| font-size:.82rem; | |
| color:var(--ink-soft); | |
| margin:10px 0 4px; | |
| } | |
| input[type=range]{width:100%;} | |
| input[type=color]{ | |
| width:100%; | |
| height:32px; | |
| border:1px solid var(--line); | |
| border-radius:4px; | |
| background:none; | |
| padding:0; | |
| } | |
| .val{font-size:.78rem; color:var(--accent);} | |
| select{ | |
| width:100%; | |
| padding:7px 8px; | |
| border:1px solid var(--line); | |
| border-radius:4px; | |
| background:#fffdf8; | |
| color:var(--ink); | |
| font-family:inherit; | |
| font-size:.85rem; | |
| } | |
| #angleGroup.disabled{opacity:.35;} | |
| #angleGroup.disabled input{cursor:not-allowed;} | |
| button{ | |
| width:100%; | |
| margin-top:10px; | |
| padding:9px 10px; | |
| border:1px solid var(--accent); | |
| background:var(--paper); | |
| color:var(--accent); | |
| border-radius:4px; | |
| font-family:inherit; | |
| font-size:.85rem; | |
| letter-spacing:.03em; | |
| cursor:pointer; | |
| transition:background .15s,color .15s; | |
| } | |
| button:hover{background:var(--accent); color:#fff9f0;} | |
| button.primary{background:var(--accent); color:#fff9f0;} | |
| button.primary:hover{opacity:.88;} | |
| #canvasHolder{ | |
| background:var(--paper); | |
| border:1px solid var(--line); | |
| border-radius:6px; | |
| box-shadow: inset 0 0 0 1px #fff, 0 1px 3px rgba(0,0,0,.08); | |
| touch-action:none; | |
| flex:1 1 0%; | |
| min-width:260px; | |
| max-width:850px; | |
| max-height:70vh; | |
| aspect-ratio: 900 / 620; | |
| } | |
| svg{display:block; width:100%; height:100%; cursor:crosshair;} | |
| .stat{ | |
| display:flex; | |
| justify-content:space-between; | |
| font-size:.78rem; | |
| color:var(--ink-soft); | |
| padding:3px 0; | |
| border-bottom:1px dashed var(--line); | |
| } | |
| .stat span:last-child{color:var(--ink); font-variant-numeric:tabular-nums;} | |
| .note{ | |
| font-size:.75rem; | |
| color:var(--ink-soft); | |
| line-height:1.5; | |
| margin-top:10px; | |
| overflow-wrap:anywhere; | |
| } | |
| code{ | |
| background:#efe7d6; | |
| padding:1px 4px; | |
| border-radius:3px; | |
| font-size:.75rem; | |
| overflow-wrap:anywhere; | |
| word-break:break-word; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <h1>Calligraphy Stroke Recorder</h1> | |
| <p class="sub">Draw with a broad-nib pen. Every sample (time, x, y, pressure) is captured — export the artwork as SVG, the raw data as CSV.</p> | |
| <div class="wrap"> | |
| <div class="panel" style="width:180px;"> | |
| <h2>Nib</h2> | |
| <label>Pen type</label> | |
| <select id="penType"> | |
| <option value="calligraphy">Calligraphy Pen</option> | |
| <option value="marker">Marker</option> | |
| <option value="brush">Brush</option> | |
| <option value="pencil">Pencil</option> | |
| <option value="highlighter">Highlighter</option> | |
| </select> | |
| <label>Width <span class="val" id="widthVal">22</span>px</label> | |
| <input type="range" id="widthInput" min="2" max="70" value="22"> | |
| <div id="angleGroup"> | |
| <label>Angle <span class="val" id="angleVal">40</span>°</label> | |
| <input type="range" id="angleInput" min="0" max="180" value="40"> | |
| </div> | |
| <label>Smoothness <span class="val" id="smoothVal">40</span>%</label> | |
| <input type="range" id="smoothInput" min="0" max="100" value="40"> | |
| <label>Ink color</label> | |
| <input type="color" id="colorInput" value="#2b2420"> | |
| <label style="margin-top:14px;"> | |
| <input type="checkbox" id="pressureToggle" checked style="width:auto; vertical-align:middle;"> | |
| use stylus pressure (if available) | |
| </label> | |
| <label style="margin-top:10px;"> | |
| <input type="checkbox" id="speedVizToggle" style="width:auto; vertical-align:middle;"> | |
| visualize speed (color gradient) | |
| </label> | |
| <div class="note" style="margin-top:4px;">blue = slow, red = fast — recolors every stroke on screen, doesn't change the CSV.</div> | |
| <button id="undoBtn">Undo last stroke</button> | |
| <button id="clearBtn">Clear all</button> | |
| </div> | |
| <div id="canvasHolder"> | |
| <svg id="board" viewBox="0 0 900 620" preserveAspectRatio="xMidYMid meet"></svg> | |
| </div> | |
| <div class="panel" style="width:210px;"> | |
| <h2>Session</h2> | |
| <div class="stat"><span>Strokes</span><span id="strokeCount">0</span></div> | |
| <div class="stat"><span>Samples</span><span id="sampleCount">0</span></div> | |
| <div class="stat"><span>Last stroke duration</span><span id="lastDur">–</span></div> | |
| <button class="primary" id="exportSvg">Download SVG</button> | |
| <button class="primary" id="exportCsv">Download CSV</button> | |
| <div class="note"> | |
| Smoothness reshapes the drawn/exported curve only — the CSV always contains your raw, unsmoothed pointer samples.<br><br> | |
| CSV columns: <code>stroke_id,point_index,t_ms,x,y,pressure,vx,vy</code> (vx/vy in px/ms). Each stroke is also a separate <code><path></code> in the exported SVG. | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| (function(){ | |
| const svg = document.getElementById('board'); | |
| const NS = 'http://www.w3.org/2000/svg'; | |
| const widthInput = document.getElementById('widthInput'); | |
| const angleInput = document.getElementById('angleInput'); | |
| const angleGroup = document.getElementById('angleGroup'); | |
| const smoothInput = document.getElementById('smoothInput'); | |
| const colorInput = document.getElementById('colorInput'); | |
| const penTypeSelect = document.getElementById('penType'); | |
| const widthVal = document.getElementById('widthVal'); | |
| const angleVal = document.getElementById('angleVal'); | |
| const smoothVal = document.getElementById('smoothVal'); | |
| const pressureToggle = document.getElementById('pressureToggle'); | |
| const speedVizToggle = document.getElementById('speedVizToggle'); | |
| speedVizToggle.addEventListener('change', redrawAll); | |
| // Speed-gradient color scale: blue (slow) -> red (fast). SPEED_COLOR_MAX is | |
| // a fixed reference (px/ms) rather than per-stroke max, so a slow careful | |
| // stroke and a fast scrawl are still comparable to each other on screen. | |
| const SPEED_COLOR_MAX = 2.2; | |
| function speedToColor(speed){ | |
| const norm = Math.min(1, Math.max(0, speed / SPEED_COLOR_MAX)); | |
| const hue = 210 - 210*norm; | |
| return `hsl(${hue.toFixed(0)}, 75%, 45%)`; | |
| } | |
| // Pen presets, roughly mirroring Inkscape's calligraphy tool profiles. | |
| // angleDependent: true = fixed chisel-nib angle (calligraphy); false = a | |
| // round tip whose offset always follows perpendicular to the stroke | |
| // direction (marker/brush/pencil/highlighter). | |
| // pressureSensitivity: how strongly pressure/speed affects width (1.0 = | |
| // full raw sensitivity; <1 flattens toward constant width; >1 exaggerates | |
| // taper, as with a soft brush). | |
| // opacity: fill-opacity of the stroke. | |
| // capRound: round end caps (marker/brush/etc.) vs flat chisel-cut ends. | |
| const PEN_PRESETS = { | |
| calligraphy: { angleDependent:true, pressureSensitivity:1.0, opacity:1, capRound:false }, | |
| marker: { angleDependent:false, pressureSensitivity:0.25,opacity:1, capRound:true }, | |
| brush: { angleDependent:false, pressureSensitivity:1.4, opacity:1, capRound:true }, | |
| pencil: { angleDependent:false, pressureSensitivity:0.5, opacity:0.85, capRound:true }, | |
| highlighter: { angleDependent:false, pressureSensitivity:0.15,opacity:0.35, capRound:true }, | |
| }; | |
| function updatePenTypeUI(){ | |
| const isCalligraphy = penTypeSelect.value === 'calligraphy'; | |
| angleInput.disabled = !isCalligraphy; | |
| angleGroup.classList.toggle('disabled', !isCalligraphy); | |
| } | |
| penTypeSelect.addEventListener('change', updatePenTypeUI); | |
| updatePenTypeUI(); | |
| widthInput.addEventListener('input', ()=> widthVal.textContent = widthInput.value); | |
| angleInput.addEventListener('input', ()=> angleVal.textContent = angleInput.value); | |
| smoothInput.addEventListener('input', ()=>{ | |
| smoothVal.textContent = smoothInput.value; | |
| redrawAll(); // re-render existing strokes at the new smoothness immediately | |
| }); | |
| // Maps the 0-100 UI slider to an EMA alpha in (0.05, 1]. | |
| // alpha = 1 -> no smoothing (curve passes through every raw sample) | |
| // alpha -> 0 -> heavy smoothing (curve lags well behind the raw samples) | |
| function currentAlpha(){ | |
| const s = parseFloat(smoothInput.value); | |
| return 1 - (s/100)*0.95; | |
| } | |
| // strokes[] : { color, width, angleDeg, presetKey, points:[{t,x,y,pressure}] } | |
| let strokes = []; | |
| let current = null; // stroke being drawn right now | |
| let liveGroup = null; | |
| let strokeStartTime = 0; | |
| function svgPoint(evt){ | |
| const pt = svg.createSVGPoint(); | |
| pt.x = evt.clientX; | |
| pt.y = evt.clientY; | |
| const ctm = svg.getScreenCTM().inverse(); | |
| const p = pt.matrixTransform(ctm); | |
| return { x: p.x, y: p.y }; | |
| } | |
| // Zero-phase exponential smoothing (forward pass then backward pass) on the | |
| // raw samples. This is what gives the "lagging nib with mass" feel Inkscape's | |
| // calligraphy tool has, but applied symmetrically so the smoothed curve | |
| // still tracks the true shape rather than skewing toward the draw direction. | |
| function smoothPoints(points, alpha){ | |
| if(points.length < 3 || alpha >= 0.999){ | |
| return points.map(p => ({x:p.x, y:p.y, pressure: p.pressure!=null?p.pressure:0.5, speed: p.speed!=null?p.speed:0})); | |
| } | |
| const fwd = [{...points[0], pressure: points[0].pressure!=null?points[0].pressure:0.5, speed: points[0].speed!=null?points[0].speed:0}]; | |
| for(let i=1;i<points.length;i++){ | |
| const prev = fwd[i-1]; | |
| const raw = points[i]; | |
| const pr = raw.pressure!=null ? raw.pressure : 0.5; | |
| const sp = raw.speed!=null ? raw.speed : 0; | |
| fwd.push({ | |
| x: prev.x + alpha*(raw.x - prev.x), | |
| y: prev.y + alpha*(raw.y - prev.y), | |
| pressure: prev.pressure + alpha*(pr - prev.pressure), | |
| speed: prev.speed + alpha*(sp - prev.speed) | |
| }); | |
| } | |
| const bwd = new Array(fwd.length); | |
| bwd[fwd.length-1] = fwd[fwd.length-1]; | |
| for(let i=fwd.length-2;i>=0;i--){ | |
| const next = bwd[i+1]; | |
| bwd[i] = { | |
| x: next.x + alpha*(fwd[i].x - next.x), | |
| y: next.y + alpha*(fwd[i].y - next.y), | |
| pressure: next.pressure + alpha*(fwd[i].pressure - next.pressure), | |
| speed: next.speed + alpha*(fwd[i].speed - next.speed) | |
| }; | |
| } | |
| return bwd; | |
| } | |
| // Catmull-Rom interpolation of a single scalar between p1..p2 (with | |
| // neighbors p0, p3) at parameter t in [0,1]. | |
| function crLerp(p0,p1,p2,p3,t){ | |
| const t2=t*t, t3=t2*t; | |
| return 0.5*((2*p1) + (-p0+p2)*t + (2*p0-5*p1+4*p2-p3)*t2 + (-p0+3*p1-3*p2+p3)*t3); | |
| } | |
| // Densify a polyline into a smooth curve by sampling `steps` extra points | |
| // per segment via Catmull-Rom, interpolating x, y, pressure, and speed. | |
| function densify(points, steps){ | |
| if(points.length < 3) return points; | |
| const out = [points[0]]; | |
| for(let i=0;i<points.length-1;i++){ | |
| const p0 = points[i-1] || points[i]; | |
| const p1 = points[i]; | |
| const p2 = points[i+1]; | |
| const p3 = points[i+2] || p2; | |
| for(let s=1;s<=steps;s++){ | |
| const t = s/steps; | |
| out.push({ | |
| x: crLerp(p0.x,p1.x,p2.x,p3.x,t), | |
| y: crLerp(p0.y,p1.y,p2.y,p3.y,t), | |
| pressure: crLerp(p0.pressure,p1.pressure,p2.pressure,p3.pressure,t), | |
| speed: crLerp(p0.speed,p1.speed,p2.speed,p3.speed,t) | |
| }); | |
| } | |
| } | |
| return out; | |
| } | |
| // Central-difference speed magnitude (px/ms) at each raw sample — same | |
| // calculation as the vx/vy columns in the CSV, collapsed to one number. | |
| function computePointSpeeds(rawPoints){ | |
| const n = rawPoints.length; | |
| return rawPoints.map((p,i)=>{ | |
| if(n < 2) return {...p, speed:0}; | |
| let lo, hi; | |
| if(i === 0){ lo=0; hi=1; } | |
| else if(i === n-1){ lo=n-2; hi=n-1; } | |
| else { lo=i-1; hi=i+1; } | |
| const dt = rawPoints[hi].t - rawPoints[lo].t; | |
| let speed = 0; | |
| if(dt > 0){ | |
| const dx = rawPoints[hi].x - rawPoints[lo].x; | |
| const dy = rawPoints[hi].y - rawPoints[lo].y; | |
| speed = Math.hypot(dx,dy) / dt; | |
| } | |
| return {...p, speed}; | |
| }); | |
| } | |
| // Offset direction at each point: a fixed nib angle for calligraphy pens, | |
| // or perpendicular-to-travel for round-tip pens (marker/brush/etc). | |
| function offsetDirs(points, angleDependent, angleDeg){ | |
| if(angleDependent){ | |
| const rad = angleDeg * Math.PI/180; | |
| const d = { dx: Math.cos(rad), dy: Math.sin(rad) }; | |
| return points.map(()=>d); | |
| } | |
| const dirs = []; | |
| for(let i=0;i<points.length;i++){ | |
| const a = points[Math.max(0,i-1)]; | |
| const b = points[Math.min(points.length-1,i+1)]; | |
| let tx = b.x-a.x, ty = b.y-a.y; | |
| const len = Math.hypot(tx,ty) || 1; | |
| tx/=len; ty/=len; | |
| dirs.push({ dx: -ty, dy: tx }); | |
| } | |
| return dirs; | |
| } | |
| function effectivePressure(p, sensitivity){ | |
| const raw = p!=null ? p : 0.5; | |
| const eff = 1 - sensitivity*(1-raw); | |
| return Math.max(0.08, Math.min(1.6, eff)); | |
| } | |
| function circlePath(cx, cy, r){ | |
| return `M ${(cx-r).toFixed(2)} ${cy.toFixed(2)} A ${r.toFixed(2)} ${r.toFixed(2)} 0 1 0 ${(cx+r).toFixed(2)} ${cy.toFixed(2)} A ${r.toFixed(2)} ${r.toFixed(2)} 0 1 0 ${(cx-r).toFixed(2)} ${cy.toFixed(2)} Z `; | |
| } | |
| // Build a stroke's fill geometry as a train of small, individually convex | |
| // quads (one per sampled segment) rather than one long outline. All quads | |
| // wind the same direction, so when merged into one path with | |
| // fill-rule="nonzero", any place the pen crosses back over itself simply | |
| // adds winding (still filled solid) instead of subtracting it (which is | |
| // what caused strokes to "punch a hole"/flip to the background color when | |
| // writing over themselves). Each quad also carries its own local speed | |
| // (px/ms), so the same geometry can drive either the normal uniform-ink | |
| // render or the optional per-segment speed-gradient render. | |
| function computeStrokeGeometry(rawPoints, baseWidth, angleDeg, presetKey, alpha){ | |
| const preset = PEN_PRESETS[presetKey] || PEN_PRESETS.calligraphy; | |
| if(rawPoints.length === 0) return { preset, quads: [] }; | |
| if(rawPoints.length === 1){ | |
| const p = rawPoints[0]; | |
| const r = (baseWidth * effectivePressure(p.pressure, preset.pressureSensitivity)) / 2; | |
| return { preset, single: circlePath(p.x, p.y, r) }; | |
| } | |
| let pts = computePointSpeeds(rawPoints); | |
| pts = smoothPoints(pts, alpha); | |
| pts = densify(pts, 4); | |
| const dirs = offsetDirs(pts, preset.angleDependent, angleDeg); | |
| const quads = []; | |
| for(let i=0;i<pts.length-1;i++){ | |
| const p1 = pts[i], p2 = pts[i+1]; | |
| const hw1 = (baseWidth * effectivePressure(p1.pressure, preset.pressureSensitivity)) / 2; | |
| const hw2 = (baseWidth * effectivePressure(p2.pressure, preset.pressureSensitivity)) / 2; | |
| const d1 = dirs[i], d2 = dirs[i+1]; | |
| const l1x = p1.x + d1.dx*hw1, l1y = p1.y + d1.dy*hw1; | |
| const r1x = p1.x - d1.dx*hw1, r1y = p1.y - d1.dy*hw1; | |
| const l2x = p2.x + d2.dx*hw2, l2y = p2.y + d2.dy*hw2; | |
| const r2x = p2.x - d2.dx*hw2, r2y = p2.y - d2.dy*hw2; | |
| const d = `M ${l1x.toFixed(2)} ${l1y.toFixed(2)} L ${l2x.toFixed(2)} ${l2y.toFixed(2)} L ${r2x.toFixed(2)} ${r2y.toFixed(2)} L ${r1x.toFixed(2)} ${r1y.toFixed(2)} Z `; | |
| quads.push({ d, speed: (p1.speed + p2.speed)/2 }); | |
| } | |
| if(preset.capRound){ | |
| const startHw = (baseWidth * effectivePressure(pts[0].pressure, preset.pressureSensitivity)) / 2; | |
| const endHw = (baseWidth * effectivePressure(pts[pts.length-1].pressure, preset.pressureSensitivity)) / 2; | |
| quads.push({ d: circlePath(pts[0].x, pts[0].y, startHw), speed: pts[0].speed }); | |
| quads.push({ d: circlePath(pts[pts.length-1].x, pts[pts.length-1].y, endHw), speed: pts[pts.length-1].speed }); | |
| } | |
| return { preset, quads }; | |
| } | |
| // Normal (uniform ink color) rendering: merge all quads into one path. | |
| function buildStrokePath(rawPoints, baseWidth, angleDeg, presetKey, alpha){ | |
| const geo = computeStrokeGeometry(rawPoints, baseWidth, angleDeg, presetKey, alpha); | |
| if(geo.single !== undefined) return { d: geo.single, opacity: geo.preset.opacity }; | |
| return { d: geo.quads.map(q=>q.d).join(''), opacity: geo.preset.opacity }; | |
| } | |
| function estimatePressure(prevPt, pt, dtMs){ | |
| // Faux-pressure for mouse: faster movement -> thinner stroke. | |
| if(!prevPt) return 0.7; | |
| const dist = Math.hypot(pt.x - prevPt.x, pt.y - prevPt.y); | |
| const speed = dist / Math.max(dtMs, 1); // px/ms | |
| const p = 1 - Math.min(speed / 1.8, 0.85); | |
| return Math.max(0.15, p); | |
| } | |
| function updateStats(){ | |
| document.getElementById('strokeCount').textContent = strokes.length; | |
| const total = strokes.reduce((a,s)=>a+s.points.length,0); | |
| document.getElementById('sampleCount').textContent = total; | |
| if(strokes.length){ | |
| const last = strokes[strokes.length-1]; | |
| const dur = last.points.length ? last.points[last.points.length-1].t : 0; | |
| document.getElementById('lastDur').textContent = dur.toFixed(0)+' ms'; | |
| } | |
| } | |
| // Render one stroke's current appearance into a <g> container: either a | |
| // single uniform-ink path (default) or one colored path per segment when | |
| // the speed-gradient visualization is on. | |
| function renderStrokeInto(groupEl, s, alpha){ | |
| while(groupEl.firstChild) groupEl.removeChild(groupEl.firstChild); | |
| const geo = computeStrokeGeometry(s.points, s.width, s.angleDeg, s.presetKey, alpha); | |
| if(geo.single !== undefined){ | |
| const el = document.createElementNS(NS,'path'); | |
| el.setAttribute('d', geo.single); | |
| el.setAttribute('fill', s.color); | |
| el.setAttribute('fill-opacity', geo.preset.opacity); | |
| el.setAttribute('stroke','none'); | |
| groupEl.appendChild(el); | |
| return; | |
| } | |
| if(speedVizToggle.checked){ | |
| for(const q of geo.quads){ | |
| const el = document.createElementNS(NS,'path'); | |
| el.setAttribute('d', q.d); | |
| el.setAttribute('fill', speedToColor(q.speed)); | |
| el.setAttribute('fill-opacity', geo.preset.opacity); | |
| el.setAttribute('stroke','none'); | |
| groupEl.appendChild(el); | |
| } | |
| } else { | |
| const el = document.createElementNS(NS,'path'); | |
| el.setAttribute('d', geo.quads.map(q=>q.d).join('')); | |
| el.setAttribute('fill', s.color); | |
| el.setAttribute('fill-rule','nonzero'); | |
| el.setAttribute('fill-opacity', geo.preset.opacity); | |
| el.setAttribute('stroke','none'); | |
| groupEl.appendChild(el); | |
| } | |
| } | |
| // Same logic as renderStrokeInto, but returns SVG markup text for export. | |
| function strokeMarkup(s, alpha){ | |
| const geo = computeStrokeGeometry(s.points, s.width, s.angleDeg, s.presetKey, alpha); | |
| if(geo.single !== undefined){ | |
| return ` <path d="${geo.single}" fill="${s.color}" fill-opacity="${geo.preset.opacity}" stroke="none"/>\n`; | |
| } | |
| if(speedVizToggle.checked){ | |
| let out = ''; | |
| for(const q of geo.quads){ | |
| out += ` <path d="${q.d}" fill="${speedToColor(q.speed)}" fill-opacity="${geo.preset.opacity}" stroke="none"/>\n`; | |
| } | |
| return out; | |
| } | |
| const d = geo.quads.map(q=>q.d).join(''); | |
| return ` <path d="${d}" fill="${s.color}" fill-rule="nonzero" fill-opacity="${geo.preset.opacity}" stroke="none"/>\n`; | |
| } | |
| function pointerDown(evt){ | |
| svg.setPointerCapture(evt.pointerId); | |
| strokeStartTime = performance.now(); | |
| const {x,y} = svgPoint(evt); | |
| const pressure = (pressureToggle.checked && evt.pressure && evt.pressure>0 && evt.pointerType==='pen') | |
| ? evt.pressure : estimatePressure(null, {x,y}, 0); | |
| current = { | |
| color: colorInput.value, | |
| width: parseFloat(widthInput.value), | |
| angleDeg: parseFloat(angleInput.value), | |
| presetKey: penTypeSelect.value, | |
| points: [{t:0, x, y, pressure}] | |
| }; | |
| liveGroup = document.createElementNS(NS,'g'); | |
| svg.appendChild(liveGroup); | |
| renderLive(); | |
| evt.preventDefault(); | |
| } | |
| function pointerMove(evt){ | |
| if(!current) return; | |
| const {x,y} = svgPoint(evt); | |
| const t = performance.now() - strokeStartTime; | |
| const prev = current.points[current.points.length-1]; | |
| const pressure = (pressureToggle.checked && evt.pressure && evt.pressure>0 && evt.pointerType==='pen') | |
| ? evt.pressure : estimatePressure(prev, {x,y}, t-prev.t); | |
| current.points.push({t, x, y, pressure}); | |
| renderLive(); | |
| evt.preventDefault(); | |
| } | |
| function pointerUp(evt){ | |
| if(!current) return; | |
| if(current.points.length){ | |
| strokes.push(current); | |
| } else if(liveGroup){ | |
| liveGroup.remove(); | |
| } | |
| current = null; | |
| liveGroup = null; | |
| updateStats(); | |
| } | |
| function renderLive(){ | |
| if(!liveGroup || !current) return; | |
| renderStrokeInto(liveGroup, current, currentAlpha()); | |
| } | |
| svg.addEventListener('pointerdown', pointerDown); | |
| svg.addEventListener('pointermove', pointerMove); | |
| svg.addEventListener('pointerup', pointerUp); | |
| svg.addEventListener('pointercancel', pointerUp); | |
| svg.addEventListener('pointerleave', (e)=>{ if(current) pointerUp(e); }); | |
| document.getElementById('undoBtn').addEventListener('click', ()=>{ | |
| strokes.pop(); | |
| redrawAll(); | |
| }); | |
| document.getElementById('clearBtn').addEventListener('click', ()=>{ | |
| strokes = []; | |
| redrawAll(); | |
| }); | |
| function redrawAll(){ | |
| svg.innerHTML = ''; | |
| const alpha = currentAlpha(); | |
| for(const s of strokes){ | |
| const g = document.createElementNS(NS,'g'); | |
| svg.appendChild(g); | |
| renderStrokeInto(g, s, alpha); | |
| } | |
| updateStats(); | |
| } | |
| function download(filename, text){ | |
| const blob = new Blob([text], {type:'text/plain'}); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; a.download = filename; | |
| document.body.appendChild(a); a.click(); a.remove(); | |
| URL.revokeObjectURL(url); | |
| } | |
| document.getElementById('exportSvg').addEventListener('click', ()=>{ | |
| if(!strokes.length){ alert('Draw at least one stroke first.'); return; } | |
| let body = ''; | |
| const alpha = currentAlpha(); | |
| for(const s of strokes){ | |
| body += strokeMarkup(s, alpha); | |
| } | |
| const full = `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="900" height="620" viewBox="0 0 900 620">\n${body}</svg>\n`; | |
| download('calligraphy.svg', full); | |
| }); | |
| document.getElementById('exportCsv').addEventListener('click', ()=>{ | |
| if(!strokes.length){ alert('Draw at least one stroke first.'); return; } | |
| let rows = ['stroke_id,point_index,t_ms,x,y,pressure,vx,vy']; | |
| strokes.forEach((s, sIdx)=>{ | |
| const pts = s.points; | |
| const n = pts.length; | |
| pts.forEach((p, i)=>{ | |
| let vx = 0, vy = 0; | |
| if(n > 1){ | |
| let lo, hi; | |
| if(i === 0){ lo = 0; hi = 1; } | |
| else if(i === n-1){ lo = n-2; hi = n-1; } | |
| else { lo = i-1; hi = i+1; } | |
| const dt = pts[hi].t - pts[lo].t; | |
| if(dt > 0){ | |
| vx = (pts[hi].x - pts[lo].x) / dt; | |
| vy = (pts[hi].y - pts[lo].y) / dt; | |
| } | |
| } | |
| rows.push(`${sIdx},${i},${p.t.toFixed(3)},${p.x.toFixed(3)},${p.y.toFixed(3)},${p.pressure.toFixed(4)},${vx.toFixed(4)},${vy.toFixed(4)}`); | |
| }); | |
| }); | |
| download('calligraphy_strokes.csv', rows.join('\n')); | |
| }); | |
| })(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment