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:

a5f78e51a50752d854d253430d9ccf0b26c58de2

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:

00ffff

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:

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,
});
Generated art for the page or post titledWhat the art, part 3: Implementation, with the content or commit hash a5f78e51a50752d854d253430d9ccf0b26c58de2 , and the topics: Art, Technology, Neurodivergence

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.