Skip to content

Instantly share code, notes, and snippets.

@erikvullings
Last active October 15, 2025 13:40
Show Gist options
  • Select an option

  • Save erikvullings/40ea3396fc011f9ea3a0c6ce3f043788 to your computer and use it in GitHub Desktop.

Select an option

Save erikvullings/40ea3396fc011f9ea3a0c6ce3f043788 to your computer and use it in GitHub Desktop.
Bezier computation, including arrow head and (optional) perpendicular lines
// =========================================================
// TYPE DEFINITIONS
// =========================================================
/** A simple 2D vector [x, y] */
export type Vector = number[];
/** Options required to calculate the end geometry. */
export interface EndGeometryOptions {
/** Backward distance from the arrow tip to the center of the arrow base. */
arrowTipToBaseDistance: number;
/** Total width of the arrow base. */
arrowBaseWidth: number;
/** Total length of a full perpendicular line (L). */
lineLength: number;
/** Fixed pixel distance from the arrow tip to the center of the first line. */
firstLineDistance: number;
/** Fixed pixel distance between the centers of subsequent lines. */
lineFixedSeparation: number;
/** Maximum number of lines that could potentially be drawn. */
maxLines: number;
/** Fractional number of lines to draw (e.g., 3.6 means 3 full lines, 1 fractional). */
lineProgress: number;
/** The t-value (0.0 to 1.0) used to sample the tangent for the arrow's orientation (e.g., 0.9). */
tangentSampleT: number;
}
/** The computed geometry result. */
export interface EndGeometryResult {
/** The three vertices of the arrow head triangle: [Tip, BaseRight, BaseLeft]. */
arrowHead: Vector[];
/** An array of line segments, each being [Start, End]. */
perpendicularLines: Vector[][];
}
/** The main interface returned by the bezier factory function. */
export interface BezierInterpolator {
/** Evaluates the curve position at parameter t (0 to 1). */
b: (t: number) => Vector;
/** Evaluates the curve tangent (derivative) at parameter t (0 to 1). */
db_dt: (t: number) => Vector;
/** Calculates the complex geometry (arrow and lines) at the end of the curve. */
calculateEndGeometry: (options: EndGeometryOptions) => EndGeometryResult;
}
// =========================================================
// UTILITY FUNCTIONS (Vector Math)
// =========================================================
function add(u: Vector, v: Vector): Vector { return u.map((x, i) => x + v[i]); }
function sub(u: Vector, v: Vector): Vector { return u.map((x, i) => x - v[i]); }
function lerpV(x: Vector, y: Vector, t: number): Vector { return x.map((v, i) => (1 - t) * v + t * y[i]); }
function magnitude(v: Vector): number { return Math.sqrt(v[0] * v[0] + v[1] * v[1]); }
function dist(u: Vector, v: Vector): number { return magnitude(sub(u, v)); }
function normalize(v: Vector): Vector {
const mag = magnitude(v);
if (mag === 0) return [0, 0];
return [v[0] / mag, v[1] / mag];
}
// =========================================================
// BEZIER CORE LOGIC
// =========================================================
/**
* Creates a Bézier interpolator and a geometry calculator tailored for complex end-of-curve graphics.
* @param points The array of control points [P0, P1, P2, P3, ...].
* @returns An object containing the curve evaluation functions and the geometry calculator.
*/
export function createBezierGeometry(points: Vector[]): BezierInterpolator {
if (points.length === 0) throw new Error('Control points array cannot be empty.');
const n = points.length - 1;
// --- Core Curve Functions ---
function _bezierIterative(p: Vector[], n: number, t: number): Vector {
let currentPoints = p.slice();
for (let i = n; i > 0; i--) {
let nextPoints: Vector[] = [];
for (let j = 0; j < i; j++) {
nextPoints.push(lerpV(currentPoints[j], currentPoints[j + 1], t));
}
currentPoints = nextPoints;
}
return currentPoints[0];
}
function _bezierPrimeIterative(p: Vector[], n: number, t: number): Vector {
if (n === 0) return [0, 0];
const derivativePoints: Vector[] = p.slice(0, n).map((p_i, i) => {
const diffVector = sub(p[i + 1], p_i);
return diffVector.map(v => v * n);
});
let currentPoints: Vector[] = derivativePoints.slice();
const nPrime = n - 1;
for (let i = nPrime; i > 0; i--) {
let nextPoints: Vector[] = [];
for (let j = 0; j < i; j++) {
nextPoints.push(lerpV(currentPoints[j], currentPoints[j + 1], t));
}
currentPoints = nextPoints;
}
return currentPoints[0];
}
const b = (t: number) => _bezierIterative(points, n, t);
const db_dt = (t: number) => _bezierPrimeIterative(points, n, t);
/**
* Finds the t-value corresponding to a point at a fixed chord-length distance L
* measured backward from t_end (t=1.0 for the curve end).
*/
function findTByChordLength(t_end: number, L: number): number {
if (L <= 0 || t_end <= 0) return t_end;
let current_t = t_end;
let current_P = b(current_t);
let distance = 0;
const step = 0.001; // Fine sampling step for arc-length approximation
while (current_t > 0) {
current_t -= step;
if (current_t < 0) current_t = 0;
const next_P = b(current_t);
const segment_length = dist(current_P, next_P);
distance += segment_length;
if (distance >= L) {
// Interpolate back to find the exact t-value
const overshoot = distance - L;
const ratio = overshoot / segment_length;
return current_t + (ratio * step);
}
current_P = next_P;
if (current_t === 0) break;
}
return 0;
}
/**
* Calculates the coordinates for the arrow and perpendicular lines based on the geometry options.
*/
function calculateEndGeometry(options: EndGeometryOptions): EndGeometryResult {
// --- 1. ARROW HEAD CALCULATION ---
const T_sample = db_dt(Math.min(options.tangentSampleT, 1.0));
const unitT_arrow = normalize(T_sample);
const unitN_arrow = [-unitT_arrow[1], unitT_arrow[0]]; // Normal for the arrow base
const P_tip = b(1.0);
const P_base_center = [
P_tip[0] - unitT_arrow[0] * options.arrowTipToBaseDistance,
P_tip[1] - unitT_arrow[1] * options.arrowTipToBaseDistance
];
const halfBase = options.arrowBaseWidth / 2;
const arrowHead: Vector[] = [
P_tip,
sub(P_base_center, unitN_arrow.map(v => v * halfBase)), // BaseRight
add(P_base_center, unitN_arrow.map(v => v * halfBase)) // BaseLeft
];
// --- 2. PERPENDICULAR LINES CALCULATION ---
const perpendicularLines: Vector[][] = [];
const lineHalfLengthBase = options.lineLength / 2;
for (let i = 0; i < options.maxLines; i++) {
// Stop if we exceed the progress
if (i >= options.lineProgress && i > Math.floor(options.lineProgress)) continue;
// Calculate the total fixed pixel distance from P_tip to the current line center
const totalDistance = options.firstLineDistance + (i * options.lineFixedSeparation);
// Find the t-value for P_center
const t_k = findTByChordLength(1.0, totalDistance);
if (t_k <= 0) break;
const P_center = b(t_k);
// Calculate Tangent and Normal at P_center (Optimization: only calculate once)
const T_k = db_dt(t_k);
const unitN_k = [-normalize(T_k)[1], normalize(T_k)[0]];
// --- Asymmetric Line Length Logic ---
let lenSide1 = lineHalfLengthBase; // Positive Normal side (Side 1)
let lenSide2 = lineHalfLengthBase; // Negative Normal side (Side 2)
if (i === Math.floor(options.lineProgress)) {
const progressFraction = options.lineProgress - i;
if (progressFraction <= 0.5) {
// Only Side 1 is drawn proportionally
lenSide1 = lineHalfLengthBase * (progressFraction / 0.5);
lenSide2 = 0;
} else {
// Side 1 is full; Side 2 is drawn fractionally based on remaining progress
lenSide1 = lineHalfLengthBase;
const side2Fraction = (progressFraction - 0.5) / 0.5;
lenSide2 = lineHalfLengthBase * side2Fraction;
}
}
if (lenSide1 <= 0 && lenSide2 <= 0) continue;
// Calculate Line Endpoints (Translation from P_center)
const P_lineStart = add(P_center, unitN_k.map(v => v * lenSide1));
const P_lineEnd = sub(P_center, unitN_k.map(v => v * lenSide2));
perpendicularLines.push([P_lineStart, P_lineEnd]);
}
return { arrowHead, perpendicularLines };
}
return { b, db_dt, calculateEndGeometry };
}
<!DOCTYPE html>
<html>
<head>
<title>Mithril Bézier Curve: Final Asymmetric Geometry</title>
<script src="https://unpkg.com/[email protected]/mithril.js"></script>
<style>
body { font-family: sans-serif; }
.container { display: flex; flex-direction: column; align-items: center; }
svg { border: 1px solid #ddd; background-color: #f9f9f9; cursor: grab; }
.control-point { fill: #3498db; cursor: grab; }
.control-line { stroke: #3498db; stroke-dasharray: 5, 5; }
.curve-path { fill: none; stroke: black; stroke-width: 3; }
.arrow-head { fill: #e74c3c; stroke: #c0392b; stroke-width: 1; }
.perp-line { stroke: #2ecc71; stroke-width: 2; }
.curve-end-point { fill: black; r: 3; }
</style>
</head>
<body>
<div id="app"></div>
<script>
// =========================================================
// 1. BEZIER GEOMETRY LOGIC (FINAL ASYMMETRIC FRACTIONAL LINES)
// =========================================================
const bezierMath = (function() {
// --- Utility Functions (Untouched for stability) ---
function add(u, v) { return u.map((x, i) => x + v[i]) }
function sub(u, v) { return u.map((x, i) => x - v[i]) }
function lerpV(x, y, t) { return x.map((v, i) => (1 - t) * v + t * y[i]) }
function magnitude(v) { return Math.sqrt(v[0] * v[0] + v[1] * v[1]) }
function normalize(v) {
const mag = magnitude(v)
if (mag === 0) return [0, 0]
return [v[0] / mag, v[1] / mag]
}
function dist(u, v) { return magnitude(sub(u, v)) }
function _bezierIterative(points, n, t) {
let currentPoints = points.slice()
for (let i = n; i > 0; i--) {
let nextPoints = []
for (let j = 0; j < i; j++) {
nextPoints.push(lerpV(currentPoints[j], currentPoints[j+1], t))
}
currentPoints = nextPoints
}
return currentPoints[0]
}
function _bezierPrimeIterative(points, n, t) {
if (n === 0) return points[0].map(() => 0)
const derivativePoints = []
for (let i = 0; i < n; i++) {
const diffVector = sub(points[i + 1], points[i])
derivativePoints.push(diffVector.map(v => v * n))
}
let currentPoints = derivativePoints.slice()
const nPrime = n - 1
for (let i = nPrime; i > 0; i--) {
let nextPoints = []
for (let j = 0; j < i; j++) {
nextPoints.push(lerpV(currentPoints[j], currentPoints[j+1], t))
}
currentPoints = nextPoints
}
return currentPoints[0]
}
function bezier(points) {
if (points.length === 0) throw new Error('no point to interpolate')
const n = points.length - 1
const b = (t) => _bezierIterative(points, n, t);
const db_dt = (t) => _bezierPrimeIterative(points, n, t);
function findTByChordLength(t_end, L) {
if (L === 0 || t_end <= 0) return t_end;
let current_t = t_end;
let current_P = b(current_t);
let distance = 0;
const step = 0.001;
while (current_t > 0) {
current_t -= step;
if (current_t < 0) current_t = 0;
const next_P = b(current_t);
const segment_length = dist(current_P, next_P);
distance += segment_length;
if (distance >= L) {
const overshoot = distance - L;
const ratio = overshoot / segment_length;
return current_t + (ratio * step);
}
current_P = next_P;
if (current_t === 0) break;
}
return 0;
}
function calculateEndGeometry(options) {
// --- ARROW HEAD ---
const T_sample = db_dt(Math.min(options.tangentSampleT, 1.0));
const unitT_arrow = normalize(T_sample);
const unitN_arrow = [-unitT_arrow[1], unitT_arrow[0]];
const P_tip = b(1.0);
const P_base_center = [
P_tip[0] - unitT_arrow[0] * options.arrowTipToBaseDistance,
P_tip[1] - unitT_arrow[1] * options.arrowTipToBaseDistance
];
const halfBase = options.arrowBaseWidth / 2;
const arrowHead = [
P_tip,
[P_base_center[0] - unitN_arrow[0] * halfBase, P_base_center[1] - unitN_arrow[1] * halfBase],
[P_base_center[0] + unitN_arrow[0] * halfBase, P_base_center[1] + unitN_arrow[1] * halfBase]
];
// --- PERPENDICULAR LINES ---
const perpendicularLines = [];
const lineHalfLengthBase = options.lineLength / 2;
for (let i = 0; i < options.maxLines; i++) {
// Stop if we exceed the progress (i.e., we are past the last fractional line)
if (i >= options.lineProgress && i > Math.floor(options.lineProgress)) continue;
// --- 1. Find P_center via Fixed Pixel Distance ---
const totalDistance = options.firstLineDistance + (i * options.lineFixedSeparation);
const t_k = findTByChordLength(1.0, totalDistance);
if (t_k <= 0) break;
const P_center = b(t_k);
// --- 2. Calculate Tangent and Normal at P_center ---
const T_k = db_dt(t_k);
const unitN_k = [-normalize(T_k)[1], normalize(T_k)[0]];
// --- 3. Determine Asymmetric Lengths ---
let lenSide1 = lineHalfLengthBase; // Positive Normal side (first half)
let lenSide2 = lineHalfLengthBase; // Negative Normal side (second half)
if (i === Math.floor(options.lineProgress)) {
const progressFraction = options.lineProgress - i; // e.g., 0.6
if (progressFraction <= 0.5) {
// Progress is only on Side 1
lenSide1 = lineHalfLengthBase * (progressFraction / 0.5); // 0.6/0.5 * 0.5 * L = 0.6 * L
lenSide2 = 0;
} else {
// Progress covers Side 1 fully, and fractionally covers Side 2
lenSide1 = lineHalfLengthBase; // Full Side 1
const side2Fraction = (progressFraction - 0.5) / 0.5; // (0.6 - 0.5) / 0.5 = 0.2
lenSide2 = lineHalfLengthBase * side2Fraction;
}
}
// Only draw if we have any length
if (lenSide1 <= 0 && lenSide2 <= 0) continue;
// --- 4. Calculate Line Endpoints ---
// P_lineStart (Positive Normal side)
const P_lineStart = [
P_center[0] + unitN_k[0] * lenSide1,
P_center[1] + unitN_k[1] * lenSide1
];
// P_lineEnd (Negative Normal side)
const P_lineEnd = [
P_center[0] - unitN_k[0] * lenSide2,
P_center[1] - unitN_k[1] * lenSide2
];
perpendicularLines.push([P_lineStart, P_lineEnd]);
}
return { arrowHead, perpendicularLines };
}
return {
b: b,
db_dt: db_dt,
calculateEndGeometry: calculateEndGeometry
}
}
return { bezier };
})();
// =========================================================
// 2. MITHRIL COMPONENT (UI)
// =========================================================
const App = {
// State
points: [
[100, 100],
[50, 400],
[450, 50],
[400, 400]
],
// SVG Size
width: 500,
height: 500,
// Geometry options
geoOptions: {
arrowTipToBaseDistance: 15,
arrowBaseWidth: 25,
lineLength: 20,
firstLineDistance: 25,
lineFixedSeparation: 30,
maxLines: 5,
lineProgress: 3.6, // Progress value is now split 0-0.5 for side 1, 0.5-1.0 for side 2
tangentSampleT: 0.9,
},
// Dragging state
draggingIndex: null,
onmousedown: (index) => (e) => {
e.redraw = false;
App.draggingIndex = index;
},
onmousemove: (e) => {
if (App.draggingIndex !== null) {
const svgRect = e.currentTarget.getBoundingClientRect();
let newX = e.clientX - svgRect.left;
let newY = e.clientY - svgRect.top;
newX = Math.max(0, Math.min(App.width, newX));
newY = Math.max(0, Math.min(App.height, newY));
App.points[App.draggingIndex] = [newX, newY];
m.redraw();
}
},
onmouseup: () => {
App.draggingIndex = null;
m.redraw();
},
view: () => {
const { bezier } = bezierMath;
const curve = bezier(App.points);
const pathData = `M ${App.points[0][0]},${App.points[0][1]} C ${App.points[1][0]},${App.points[1][1]}, ${App.points[2][0]},${App.points[2][1]}, ${App.points[3][0]},${App.points[3][1]}`;
const geometry = curve.calculateEndGeometry(App.geoOptions);
const P_end = App.points[App.points.length - 1];
return m('.container', [
m('h3', 'Final Optimized Bézier Geometry (Asymmetric Line Progress)'),
m('p', `Curve End/Arrow Tip: (${P_end[0].toFixed(1)}, ${P_end[1].toFixed(1)})`),
m('p', `Line Progress ${App.geoOptions.lineProgress}: ${Math.floor(App.geoOptions.lineProgress)} full lines, 1 partial line.`),
m('svg', {
width: App.width,
height: App.height,
onmousemove: App.onmousemove,
onmouseup: App.onmouseup,
onmouseleave: App.onmouseup,
}, [
// 1. Control lines
App.points.map((p, i) => i < App.points.length - 1 ? m('line', {
class: 'control-line',
x1: p[0], y1: p[1],
x2: App.points[i + 1][0], y2: App.points[i + 1][1]
}) : null),
// 2. The main Bézier path
m('path', {
class: 'curve-path',
d: pathData
}),
// 3. Perpendicular Lines (Green)
geometry.perpendicularLines.map((line, i) => m('line', {
class: 'perp-line',
x1: line[0][0], y1: line[0][1],
x2: line[1][0], y2: line[1][1],
key: `line-${i}`
})),
// 4. Arrow Head (Red Polygon)
m('polygon', {
class: 'arrow-head',
points: geometry.arrowHead.map(p => p.join(',')).join(' ')
}),
// 5. Visual dot at the curve end / arrow tip
m('circle', {
class: 'curve-end-point',
cx: P_end[0], cy: P_end[1]
}),
// 6. Control Points (circles)
App.points.map((p, i) => m('circle', {
class: 'control-point',
cx: p[0], cy: p[1],
r: 8,
key: i,
onmousedown: App.onmousedown(i),
style: { cursor: 'pointer' }
}))
]),
m('h4', 'Geometry Options:'),
m('div', { style: { display: 'flex', flexWrap: 'wrap', maxWidth: '500px' } }, [
// Input controls for geometry options
Object.keys(App.geoOptions).map(key => {
const isT = key.includes('T');
const isProgress = key.includes('Progress');
const isMaxLines = key.includes('maxLines');
const maxVal = isT ? 1.0 : (isMaxLines ? 10 : 100);
const minVal = isT ? 0.01 : (isMaxLines ? 0 : 0);
const stepVal = isT || isProgress ? 0.01 : 1;
return m('label', { style: { marginRight: '20px', marginBottom: '10px' } }, [
`${key}: `,
m('input[type=range]', {
min: minVal,
max: maxVal,
step: stepVal,
value: App.geoOptions[key],
oninput: (e) => {
App.geoOptions[key] = parseFloat(e.target.value);
}
}),
m('span', { style: { marginLeft: '5px' } }, App.geoOptions[key].toFixed(isT || isProgress ? 2 : 0))
])
})
])
]);
}
};
m.mount(document.getElementById('app'), App);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment