What the art, part 3: Implementation
- Art
- Technology
- Neurodivergence
The final part in a series introducing my new site’s art project. I walk through some of the more interesting parts of how the art is generated.
Previous:
Well, I certainly was overly optimistic about how quickly I’d be able to finish this post! I took a little detour to build out my résumé because I’m currently on the job hunt (want to hire me?). I wanted to finish this series, but knowing the site was live and ready to serve up my résumé proved to be a big enough ADHD mental block that I couldn’t focus on the blog until it was done.
But we’re back to fill in some of the juicy implementation details! In the previous posts, I discussed the motivation behind the art, and the constraints that informed the artistic direction. This is how it was built.
Getting the Git
As I mentioned in Constraints, one of the goals was immutability:
art that would render the same even if a given post is edited. This
is achieved by retrieving a Git hash of the initial commit of the post.
To allow incremental commits (save early, save often!), I restrict
this hash lookup to the origin/main
branch.
For every post, I look up the initial commit hash of the post’s entry module, falling back to a format-compatible SHA-1 hash of the current content of the file on disk in cases where the post isn’t yet committed:
export const getInitialFileHash = (basePath: string) => {
const path = resolveModulePath(basePath);
const [ hash ] = getFormattedGitLogData({
decode: identity,
format: GitFormat.HASH,
path,
});
return hash ?? getSHA1Hash(path);
};
const hash = getInitialFileHash(path);
Result:
Using (abusing) the hash data
The hash represents a 160-bit number, this post's hash being approximately 9.4847. My idea was to treat it as the basis for a data structure: a set of ten numeric pairs, two hex characters per number, 8 bits each, which are then converted to { x, y }
coordinates.
export const COORDINATE_MAX = parseInt('ff', 16);
export const COORDINATE_MIN = parseInt('00', 16);
// ...
const hexPointPattern = /([0-9a-f]{2})([0-9a-f]{2})/ig;
export const toHexPointSequence = (hash: string): HexPointSequence => {
if (!isValidHash(hash)) {
throw new InvalidHashError(hash);
}
const matches = Array.from(hash.matchAll(hexPointPattern) ?? []);
const points = matches.map(([ _, x, y ]) => ({
x,
y,
} as HexPoint));
if (!isHexPointSequence(points)) {
throw new InvalidHashError(hash);
}
return points;
};
// ...
const toCoordinate = (value: HexCoordinate): Coordinate => {
const result = parseInt(value, 16);
if (result > COORDINATE_MAX || result < COORDINATE_MIN) {
throw new Error(`Not a valid coordinate: ${value}`);
}
return coordinate(result);
};
export const toPointSequence = (
hash: string,
hexPoints: HexPointSequence
): PointSequence => {
try {
const result = hexPoints.map(toPoint);
if (!isPointSequence(result)) {
throw new InvalidHashError(hash);
}
return result;
}
catch {
throw new InvalidHashError(hash);
}
};
const hexPoints = toHexPointSequence(hash);
const basePoints = toPointSequence(hash, hexPoints);
While I can’t predict the initial commit hash on main/origin
for this post, it’s improbable (but possible!) that the plot points in
the example result below follow the same horizontal order as their
labels—that would require a hash where every other 8-bit
substring assigned to an x
coordinate has a greater value than the
previous x
. But the final art style is a series of horizontal curves
along a baseline, so the next thing I do is sort the point sequence
along the x
axis.
const sortedPoints = sortBy(basePoints, ({ x: a }, { x: b }) => (
Number(a) === Number(b)
? 0
: Number(a) > Number(b)
? 1
: -1
));
Sorting is off by default in the example, to allow readers to toggle the before/after state.
Result:
Aspect ratio & padding adjustments
Of course, the final art rendering isn’t square, it’s much wider than
it is tall. With some adjustments for very small & very large
viewports, its aspect ratio is roughly five times the
golden ratio (there’s no significance to this other than
that it was the first ratio I tried), plus a small amount of padding—on
the x
axis to begin and end the blobs on the art’s baseline, and on
the y
axis to leave some room for overshoot once the curves are
computed.
export const scalePoint = <
X extends number,
Y extends number
>({ x, y }: Point, {
xScale,
xShift,
yScale,
yShift,
}: ScalePointOptions<X, Y>): ScaledPoint<X, Y> => (
scaledPoint({
x: (x + xShift) * xScale,
xScale,
y: (y + yShift) * yScale,
yScale,
})
);
const scaledPoints = sortedPoints.map((point) => (
scalePoint(point, scaleOptions)
));
Result:
Connecting the dots
In the final art rendering, each point is joined into overlapping
curves, each segment curving from approximately the previous point’s
x
(or 0
) at the y
the baseline to the following point’s
x
(or xMax
), also at the baseline.
export const getNaiveSegments = <
X extends number,
Y extends number
>({
points,
xMax,
xScale,
yScale,
}: GetSegmentsOptions<X, Y>): SegmentList<X, Y> => (
[
scaledPoint({
x: 0,
xScale,
y: 0,
yScale,
}),
...points,
scaledPoint({
x: xMax,
xScale,
y: 0,
yScale,
}),
].reduce<SegmentList<X, Y>>((
acc,
mid,
index,
points
) => {
if (index === 0 || index === points.length - 1) {
return acc;
}
const baseline = scaledCoordinate(0, yScale);
const segment = [
{
x: points[index - 1].x,
y: baseline,
},
mid,
{
x: points[index + 1].x,
y: baseline,
},
] as const;
return [
...acc,
segment,
];
}, [])
);
const naiveSegments = getNaiveSegments({
points: scaledPoints,
xMax,
});
Why are they naïve segments? As I mentioned in Constraints, I discovered during development that sometimes certain hashes would create shapes which were inappropriate for the kind of content I wanted on my site. So after constructing these naïve segments, I walk through them again in hopes of detecting that scenario.
I should add a caveat: this was entirely a process trial and error, and produced some moderately ugly “magic” code. I try not to write code like this, but if you’re making art sometimes you’ve gotta break a few eggs!
/**
* Generating art will be risk-free fun, I thought...
*/
export const getNonPhallicSegments = <X extends number, Y extends number>({
segments,
xMax,
xScale,
yScale,
}: GetNonPhallicSegmentsOptions<X, Y>): SegmentList<X, Y> => (
segments.map<Segment<X, Y>>((segment) => {
const [
{ x: startX, y: startY },
{ x: midX, y: midY },
{ x: endX, y: endY },
] = segment;
const width = endX - startX;
const ratio = midY / width;
const maxRatio = 6;
const ratioAdjustment = maxRatio / ratio;
if (ratioAdjustment < 1) {
const ratioAdjustmentX = ratioAdjustment * 0.2;
const adjustmentX = ratioAdjustmentX * startX;
const ratioAdjustedStartX = startX - adjustmentX;
const ratioAdjustedEndX = endX + adjustmentX;
const overshootX = (
ratioAdjustedStartX < 0
? Math.abs(ratioAdjustedStartX)
: ratioAdjustedEndX > xMax
? xMax - ratioAdjustedEndX
: 0
);
const adjustedStartX = ratioAdjustedStartX + overshootX;
const adjustedEndX = ratioAdjustedEndX + overshootX;
const ratioAdjustmentY = ratioAdjustment * 0.3;
const adjustedMidX = midX + overshootX;
const adjustmentY = ratioAdjustmentY * midY;
const adjustedMidY = midY - adjustmentY;
return [
scaledPoint({
x: adjustedStartX,
xScale,
y: startY,
yScale,
}),
scaledPoint({
x: adjustedMidX,
xScale,
y: adjustedMidY,
yScale,
}),
scaledPoint({
x: adjustedEndX,
xScale,
y: endY,
yScale,
}),
];
}
return segment;
})
);
const segments = getNonPhallicSegments({
segments: naiveSegments,
xMax,
xScale,
yScale,
});
Result:
My trig crash course
We’re coming near the end! But before I get to the final step, I needed to be able to generate cubic Bézier curves for each segment.
A single cubic Bézier curve is defined by:
- A starting point
P0
:{ x0, y0 }
- A starting control point
P1
:{ x1, y1 }
- An ending control point
P2
:{ x2, y2 }
- An ending point
P3
:{ x3, y3 }
The control points determine how far the curve extends, and how fast
it arrives at the ending point. Here’s how I calculate those curves.
This is a fair bit of code, but the important bits are the
curveLine
function—which calculates an angled line to the control
point, and the curveControlPoint
function which calculates the
control point’s position from that line.
const curveLine = <X extends number, Y extends number>(
{ x: x0, y: y0 }: ScaledPoint<X, Y>,
{ x: x1, y: y1 }: ScaledPoint<X, Y>
) => {
const xLength = x1 - x0;
const yLength = y1 - y0;
return {
angle: Math.atan2(yLength, xLength),
length: Math.sqrt((xLength ** 2) + (yLength ** 2)),
};
};
const curveControlPoint = <X extends number, Y extends number>({
current,
previous,
next,
reverse,
smoothing,
xScale,
yScale,
}: CurveControlPointOptions<X, Y>): ScaledPoint<X, Y> => {
const reverseCompensation = reverse
? Math.PI
: 0;
const opposedLine = curveLine(previous, next);
const angle = opposedLine.angle + reverseCompensation;
const length = opposedLine.length * smoothing;
const { x: xCurrent, y: yCurrent } = current;
const x = xCurrent + (Math.cos(angle) * length);
const y = yCurrent + (Math.sin(angle) * length);
return {
x: scaledCoordinate(x, xScale),
y: scaledCoordinate(y, yScale),
};
};
export const cubicBezierPoints = <X extends number, Y extends number>({
index,
point,
points,
smoothing,
xScale,
yScale,
}: CubicBezierPointsOptions<X, Y>): CubicBezierPoints<X, Y> => {
const startCurrent = points[index - 1];
if (startCurrent == null) {
throw new Error(
'Cannot build cubic bezier points, no point before the provided index.'
);
}
const startPrevious = points[index - 2] ?? startCurrent;
const startControl = curveControlPoint({
current: startCurrent,
previous: startPrevious,
next: point,
reverse: false,
smoothing,
xScale,
yScale,
});
const previous = startCurrent;
const next = points[index + 1] ?? point;
const endControl = curveControlPoint({
current: point,
previous: previous,
next,
reverse: true,
smoothing,
xScale,
yScale,
});
return [ startControl, endControl, point ];
};
const cubicPoints = segments.map((segment) => (
segment.reduce((acc, point, index) => {
if (index === 0) {
return acc;
}
const segmentPoints = cubicBezierPoints({
index,
point,
points: segment,
smoothing,
xScale,
yScale,
});
return [
...acc,
segmentPoints,
]
}, []);
));
Result:
Now, the magic happens
Now that I can get those smooth curves, I put on the final touches.
Again, this is a fair bit of code, but essentially what’s happening
is that for each segment, I calculate a slightly adjusted curve
above the y
axis baseline, then a slightly differently adjusted
curve approximately mirroring it below the same baseline.
These adjustments were chosen to fit a general theme in the site design, where most everything is laid out slightly to the left (assuming you’re on a screen large enough for that to kick in), and to add just a little more visual variety than the curves alone.
const getCubicPoints = <X extends number, Y extends number>({
segment,
smoothing,
xScale,
yScale,
}: GetCubicPointsOptions<X, Y>) => (
segment.reduce<readonly string[]>((
acc: readonly string[],
point: ScaledPoint<X, Y>,
index: number
) => {
if (index === 0) {
return acc;
}
const segmentPoints = cubicBezierPoints({
index,
point,
points: segment,
smoothing,
xScale,
yScale,
});
const result = segmentPoints.map((point) => (
`${point.x},${point.y}`
)).join(' ');
return [
...acc,
`C ${result}`,
];
}, [])
);
export const getSegmentPaths = <X extends number, Y extends number>({
baseYCoordinate,
isBaselineBelowMidpoint,
segments,
xScale,
yMax,
yScale,
yTilt = false,
}: GetSegmentPathsOptions<X, Y>) => (
segments.reduce((
acc,
segment,
index,
segments
) => {
const { length } = segments;
const [
baseStartPoint,
baseMidPoint,
baseEndPoint,
] = segment;
const { x: startX, y: baseStartY } = baseStartPoint;
const { x: midX, y: midY } = baseMidPoint;
const { x: endX, y: baseEndY } = baseEndPoint;
const width = endX - startX;
const smoothing = width === 0
? 0
: Math.max(midY / width * SMOOTHING_RATIO, MIN_SMOOTHING);
const Y_TILT = yTilt
? 0.1
: 0;
const Y_TILT_NEG = 1 - Y_TILT;
const Y_TILT_POS = 1 + Y_TILT;
const startYTilt = index % 2 === 0
? Y_TILT_NEG
: Y_TILT_POS;
const startY = isBaselineBelowMidpoint
? baseStartY + baseYCoordinate
: yMax - baseStartY + baseYCoordinate;
const startPoint: ScaledPoint<X, Y> = {
x: startX,
y: scaledCoordinate(startY * startYTilt, yScale),
};
const endYTilt = index % 2 === 0
? Y_TILT_NEG
: Y_TILT_POS;
const endY = isBaselineBelowMidpoint
? baseEndY + baseYCoordinate
: yMax - baseEndY + baseYCoordinate;
const endPoint: ScaledPoint<X, Y> = {
x: scaledCoordinate(endX, xScale),
y: scaledCoordinate(endY * endYTilt, yScale),
};
const startMidXDistance = midX - startX;
const midEndXDistance = endX - midX;
const forwardMidPointXAdjustment = midEndXDistance > startMidXDistance
? 0
: 0 - ((midX - startX) * MID_POINT_TILT);
const midPointYAdjustment = (length - index) * (yScale / 100 * yMax);
const forwardMidPoint: ScaledPoint<X, Y> = {
x: scaledCoordinate(midX + forwardMidPointXAdjustment, xScale),
y: scaledCoordinate(midY - midPointYAdjustment, yScale),
};
const forwardSegment: Segment<X, Y> = [
startPoint,
forwardMidPoint,
endPoint,
];
const forwardPoints = getCubicPoints({
segment: forwardSegment,
smoothing,
xScale,
yScale,
});
const reverseMidPointXAdjustment = midEndXDistance > startMidXDistance
? (endX - midX) * MID_POINT_TILT
: 0;
const reverseMidPoint: ScaledPoint<X, Y> = {
x: scaledCoordinate(midX + reverseMidPointXAdjustment, xScale),
y: scaledCoordinate(yMax - midPointYAdjustment, yScale),
};
const reverseSegment: Segment<X, Y> = [
endPoint,
reverseMidPoint,
startPoint,
];
const reversePoints = getCubicPoints({
segment: reverseSegment,
smoothing,
xScale,
yScale,
});
return [
...acc,
[
`M ${startPoint.x},${startPoint.y}`,
...forwardPoints,
...reversePoints,
'Z',
].join(' '),
];
}, [] as readonly string[])
);
const segmentPaths = getSegmentPaths({
baseYCoordinate,
segments,
xScale,
yMax,
isBaselineBelowMidpoint,
yScale,
});
My mistakes
One thing that was challenging building both the original art
project, as well as the examples for this post, is that I’m working
with numerical data, which I expect to go up as it increases, but
SVG renders higher numbers on the y
axis down. I’ve gotten
so frequently so turned around by this that, well, the final art
rendering shows I never quite got it right! But I like how it looks,
and I don’t want to mess with that for now.
Sometimes technically incorrect is the best kind of correct!
And another thing…
I discovered two more mistakes after the post went live! First, I
noticed the post’s timestamp was incorrect. Since the timestamp is
also defined by the time of the first commit to origin/main
,
this means that the command I’d used to retrieve the initial hash
was also wrong.
Then I realized the URL is wrong, because I began writing the post in February. Both have been corrected.