Skip to content

Instantly share code, notes, and snippets.

@wzjoriv
Created July 11, 2026 09:43
Show Gist options
  • Select an option

  • Save wzjoriv/89155bd909035c5259d9784c222bd0ca to your computer and use it in GitHub Desktop.

Select an option

Save wzjoriv/89155bd909035c5259d9784c222bd0ca to your computer and use it in GitHub Desktop.
Calligraphy Stroke Recorder
<!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>&lt;path&gt;</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