Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 47 additions & 68 deletions src/domain/filler-art.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,81 +25,60 @@ function schemaWithShapes(background: string, fills: string[]): LayoutSchema {
return { ...emptyLayoutSchema(), background, elements }
}

describe("filler art palette", () => {
it("draws its accents from the colours the layout itself uses", () => {
const palette = fillerPalette(schemaWithShapes("#fbf3e7", ["#5b927b", "#b45f52", "#27485b"]))

expect([palette.primary, palette.secondary, palette.ink].sort()).toEqual(
["#27485b", "#5b927b", "#b45f52"].sort()
)
})
function colourDistance(left: string, right: string): number {
const channels = (colour: string) =>
[1, 3, 5].map((start) => Number.parseInt(colour.slice(start, start + 2), 16))
const leftChannels = channels(left)
const rightChannels = channels(right)
return Math.hypot(...leftChannels.map((channel, index) => channel - rightChannels[index]!))
}

it("falls back to the house palette when a layout has no shapes to borrow from", () => {
describe("filler art palette", () => {
it("uses companion colours instead of borrowing colours painted by the layout", () => {
const palette = fillerPalette(emptyLayoutSchema())

expect(palette.ink).toBe("#27485b")
expect(palette.primary).not.toBe(palette.secondary)
})

it("reads a line's colour from its stroke, which is all a line paints", () => {
const schema = emptyLayoutSchema()
schema.elements = [
{
id: "rule",
type: "line",
geometry: { x: 0, y: 0, width: 60, height: 1, rotation: 0 },
opacity: 1,
fill: "transparent",
stroke: "#7a3b8f",
strokeWidth: 1.2,
},
]

expect([fillerPalette(schema).primary, fillerPalette(schema).secondary]).toContain("#7a3b8f")
})

it("ignores colours on an element the layout does not actually paint", () => {
const schema = emptyLayoutSchema()
schema.elements = [
{
id: "hidden",
type: "rectangle",
geometry: { x: 0, y: 0, width: 90, height: 90, rotation: 0 },
opacity: 0,
fill: "#7a3b8f",
stroke: "#7a3b8f",
strokeWidth: 2,
},
]
const palette = fillerPalette(schema)

expect([palette.primary, palette.secondary, palette.ink]).not.toContain("#7a3b8f")
expect(palette).toEqual({ primary: "#586aa0", secondary: "#d184a6", ink: "#6b4c6f" })
})

it("skips shape colours that would be invisible against the page background", () => {
const palette = fillerPalette(schemaWithShapes("#fbf3e7", ["#faf2e6", "#b45f52"]))

expect([palette.primary, palette.secondary, palette.ink]).not.toContain("#faf2e6")
expect([palette.primary, palette.secondary, palette.ink]).toContain("#b45f52")
})

it("inks with a light tone on a dark page and a dark tone on a pale one", () => {
const pale = fillerPalette(schemaWithShapes("#fffdf7", ["#27485b", "#f0c66f", "#b45f52"]))
const dark = fillerPalette(schemaWithShapes("#101014", ["#27485b", "#f0c66f", "#b45f52"]))

expect(pale.ink).toBe("#27485b")
expect(dark.ink).toBe("#f0c66f")
it("keeps every tone visibly distinct from the standard layouts", () => {
for (const orientation of ["landscape", "portrait"] as const) {
for (const preset of backgroundPresets("a5", orientation)) {
const paintedColours = new Set([
preset.schema.background,
...preset.schema.elements.flatMap((element) => {
if (element.type === "line") return [element.stroke]
if (element.type === "rectangle" || element.type === "circle") return [element.fill]
return []
}),
])
const palette = fillerPalette(preset.schema)

expect(
[palette.primary, palette.secondary, palette.ink].every((colour) =>
[...paintedColours].every((paintedColour) =>
Boolean(paintedColour && colourDistance(colour, paintedColour) >= 45)
)
),
preset.id
).toBe(true)
}
}
})

it("keeps every tone clear of the page it will be drawn on", () => {
const preset = backgroundPresets("a5", "landscape").find(
(candidate) => candidate.id === "geometric-collage"
)!
const palette = fillerPalette(preset.schema)

expect([palette.primary, palette.secondary, palette.ink]).not.toContain(
preset.schema.background
)
it("chooses alternate companion colours around user-painted layout colours", () => {
const paintedColours = ["#586aa0", "#d184a6", "#6b4c6f"]
const palette = fillerPalette(schemaWithShapes("#fffdf7", paintedColours))

expect([palette.primary, palette.secondary, palette.ink]).toEqual([
"#2d7f9c",
"#cf4f8c",
"#a47ac2",
])
expect(
[palette.primary, palette.secondary, palette.ink].every((colour) =>
paintedColours.every((paintedColour) => colourDistance(colour, paintedColour) >= 45)
)
).toBe(true)
})
})

Expand Down
116 changes: 41 additions & 75 deletions src/domain/filler-art.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,12 @@ export const FILLER_MOTIFS: FillerMotif[] = [
]

/**
* Fallback accents for layouts that carry no shapes of their own, matching the colours the
* background presets already use. The dark tone leads so a blank layout still gets an ink.
* A companion palette for filler art. These muted colors belong with the warm layout presets but
* do not repeat any color painted by them. Reusing layout colors made motif parts disappear when
* a transparent photo slot crossed a matching background shape.
*/
const HOUSE_ACCENTS = ["#27485b", "#5b927b", "#b45f52", "#f0c66f"]
const FILLER_ACCENTS = ["#586aa0", "#d184a6", "#6b4c6f", "#2d7f9c", "#cf4f8c", "#a47ac2", "#7f4bc0"]
const MINIMUM_ACCENT_DISTANCE = 45

interface Channels {
r: number
Expand All @@ -272,96 +274,60 @@ function parseHex(value: string): Channels | null {
}
}

function toHex({ r, g, b }: Channels): string {
return `#${[r, g, b].map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("")}`
}

function distance(left: Channels, right: Channels): number {
function colourDistance(left: Channels, right: Channels): number {
return Math.hypot(left.r - right.r, left.g - right.g, left.b - right.b)
}

function luminance({ r, g, b }: Channels): number {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
}

/** Below this an accent reads as the page background rather than as art on top of it. */
const MINIMUM_ACCENT_CONTRAST = 45

function isShape(element: LayoutElement): element is Extract<LayoutElement, { fill: string }> {
return element.type === "rectangle" || element.type === "circle" || element.type === "line"
}

/**
* The colour a shape contributes to the palette. A line has no interior, so it reads through its
* stroke; everything else reads through its fill. Outlines are deliberately not collected: accents
* rank by contrast against the page, so a dark hairline border would outrank the panel it edges.
* A fully transparent element paints nothing and contributes nothing.
*/
function paintedColor(element: Extract<LayoutElement, { fill: string }>): string | undefined {
function paintedColour(element: Extract<LayoutElement, { fill: string }>): string | undefined {
if (element.opacity === 0) return undefined
if (element.type !== "line") return element.fill
return element.strokeWidth > 0 ? element.stroke : undefined
}

/**
* Colours the filler art from the layout itself rather than from a fixed theme, so art on a sage
* and terracotta page does not arrive in someone else's palette. Background presets are copied
* into a layout's elements when applied and their preset id is not stored, so the accents are
* read back off the shapes the layout actually contains. This also covers hand-built layouts and
* presets the organizer has since recoloured.
*
* Accents are ordered by how far they sit from the page background, so the most legible colour
* leads rather than whichever panel happens to be largest.
*/
export function fillerPalette(schema: LayoutSchema): FillerPalette {
const background = parseHex(schema.background) ?? { r: 255, g: 255, b: 255 }
const candidates = schema.elements
.filter(isShape)
.flatMap((element) => {
const painted = paintedColor(element)
const channels = painted ? parseHex(painted) : null
return channels
? [{ channels, area: element.geometry.width * element.geometry.height, id: element.id }]
: []
})
.sort(
(left, right) =>
distance(right.channels, background) - distance(left.channels, background) ||
right.area - left.area ||
(left.id < right.id ? -1 : 1)
)
const painted = [
schema.background,
...schema.elements.filter(isShape).map(paintedColour),
].flatMap((colour) => {
const channels = colour ? parseHex(colour) : null
return channels ? [channels] : []
})
const selected: string[] = []

const accents: Channels[] = []
const push = (channels: Channels) => {
if (distance(channels, background) < MINIMUM_ACCENT_CONTRAST) return
if (accents.some((accent) => distance(accent, channels) < 24)) return
accents.push(channels)
}
for (const candidate of candidates) push(candidate.channels)
for (const accent of HOUSE_ACCENTS) {
if (accents.length >= 3) break
const channels = parseHex(accent)
if (channels) push(channels)
for (const accent of FILLER_ACCENTS) {
const channels = parseHex(accent)!
const clearOfPainted = painted.every(
(paintedColour) => colourDistance(channels, paintedColour) >= MINIMUM_ACCENT_DISTANCE
)
const clearOfSelected = selected.every(
(selectedColour) =>
colourDistance(channels, parseHex(selectedColour)!) >= MINIMUM_ACCENT_DISTANCE
)
if (clearOfPainted && clearOfSelected) selected.push(accent)
if (selected.length === 3) break
}
// A page coloured close to every house accent rejects them all; fall back to a paper tone.
while (accents.length < 3) accents.push({ r: 245, g: 240, b: 232 })
accents.sort((left, right) => distance(right, background) - distance(left, background))

// One tone carries the line work: the accent furthest from the page in lightness, which is the
// darkest on paper tones and the lightest on a dark page. The two most legible of the rest fill.
const backgroundLuminance = luminance(background)
const ink = accents.reduce((left, right) =>
Math.abs(luminance(left) - backgroundLuminance) >=
Math.abs(luminance(right) - backgroundLuminance)
? left
: right

// A heavily customised layout can sit close to the entire curated set. Keep the three accents
// defined in that edge case and prefer the remaining colours furthest from what the page paints.
const remaining = FILLER_ACCENTS.filter((accent) => !selected.includes(accent)).sort(
(left, right) => {
const minimumDistance = (accent: string) => {
const channels = parseHex(accent)!
return Math.min(...painted.map((paintedColour) => colourDistance(channels, paintedColour)))
}
return minimumDistance(right) - minimumDistance(left)
}
)
const [primary, secondary] = accents.filter((accent) => accent !== ink)
while (selected.length < 3) selected.push(remaining.shift()!)

return {
primary: toHex(primary!),
secondary: toHex(secondary!),
ink: toHex(ink),
primary: selected[0]!,
secondary: selected[1]!,
ink: selected[2]!,
}
}

Expand Down