188 lines
6.7 KiB
TypeScript
188 lines
6.7 KiB
TypeScript
import { MMCQ } from "@vibrant/quantizer-mmcq";
|
|
import type { CanvasState } from "@dada/shared-contracts";
|
|
import { P0A_COLOR_CARD_DEFINITIONS, type ColorCardDefinition } from "@dada/asset-renderer";
|
|
|
|
import type { CanvasElementIdentity } from "./editor-elements.js";
|
|
|
|
type CanvasElement = CanvasState["elements"][number];
|
|
|
|
export type { ColorCardDefinition } from "@dada/asset-renderer";
|
|
|
|
export interface PaletteColor {
|
|
hex: string;
|
|
population: number;
|
|
rgbValue: number;
|
|
}
|
|
|
|
export const P0A_COLOR_CARDS: readonly ColorCardDefinition[] = P0A_COLOR_CARD_DEFINITIONS;
|
|
|
|
// Coordinates are translated from the archived 320x320 renderer around its 160px center.
|
|
export const COLOR_CARD_SOURCE_GEOMETRY = {
|
|
style_01: {
|
|
bounds: { bottom: 76, left: -26, right: 26, top: -74 },
|
|
palette: { bottom: 37, left: -23, right: 23, top: -71 },
|
|
},
|
|
style_02: { bounds: { bottom: 69, left: -18, right: 18, top: -77 } },
|
|
style_08: { bounds: { bottom: 10, left: -73, right: 73, top: -9 } },
|
|
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
|
} as const;
|
|
|
|
function normalizedHex(value: string) {
|
|
return value.toUpperCase();
|
|
}
|
|
|
|
export function extractMmcqPalette(pixels: Uint8ClampedArray): PaletteColor[] {
|
|
const swatches = MMCQ(pixels, { colorCount: 10 });
|
|
if (swatches.length < 5) throw new Error("palette_five_colors_required");
|
|
return swatches
|
|
.map((swatch) => ({
|
|
hex: normalizedHex(swatch.hex),
|
|
population: swatch.population,
|
|
rgbValue: (Math.round(swatch.r) << 16) + (Math.round(swatch.g) << 8) + Math.round(swatch.b),
|
|
}))
|
|
.sort((left, right) => right.population - left.population || left.rgbValue - right.rgbValue)
|
|
.slice(0, 5);
|
|
}
|
|
|
|
export function extractPaletteFromImage(image: CanvasImageSource & { height: number; width: number }) {
|
|
const longestEdge = Math.max(image.width, image.height);
|
|
if (longestEdge <= 0) throw new Error("palette_image_invalid");
|
|
const scale = Math.min(1, 256 / longestEdge);
|
|
const width = Math.max(1, Math.round(image.width * scale));
|
|
const height = Math.max(1, Math.round(image.height * scale));
|
|
const surface = document.createElement("canvas");
|
|
surface.width = width;
|
|
surface.height = height;
|
|
const context = surface.getContext("2d", { willReadFrequently: true });
|
|
if (!context) throw new Error("palette_canvas_unavailable");
|
|
context.drawImage(image, 0, 0, width, height);
|
|
return extractMmcqPalette(context.getImageData(0, 0, width, height).data).map((entry) => entry.hex);
|
|
}
|
|
|
|
function validatePalette(palette: readonly string[]) {
|
|
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
|
return palette.map(normalizedHex);
|
|
}
|
|
|
|
export function createColorCardElement(
|
|
definition: ColorCardDefinition,
|
|
palette: readonly string[],
|
|
identity: CanvasElementIdentity,
|
|
zIndex: number,
|
|
): CanvasElement {
|
|
return {
|
|
colors: validatePalette(palette),
|
|
created_at: identity.createdAt,
|
|
element_id: identity.elementId,
|
|
opacity: 1,
|
|
position: { x: 0.5, y: 0.5 },
|
|
resource_version: "wp4-provider-v1",
|
|
rotation: 0,
|
|
scale: { x: 1, y: 1 },
|
|
style_id: definition.styleId,
|
|
style_parameters: {
|
|
mapping_status: definition.mappingStatus,
|
|
palette_algorithm_version: "mmcq-v1",
|
|
renderer_name: definition.rendererName,
|
|
},
|
|
template_or_asset_id: definition.cardId,
|
|
type: "color_card",
|
|
z_index: zIndex,
|
|
};
|
|
}
|
|
|
|
export function refreshColorCards(state: CanvasState, palette: readonly string[]): CanvasState {
|
|
const colors = validatePalette(palette);
|
|
const next = structuredClone(state);
|
|
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...colors] } : element);
|
|
return next;
|
|
}
|
|
|
|
export function drawColorCard(context: CanvasRenderingContext2D, element: CanvasElement) {
|
|
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
|
if (element.style_id === "style_01") {
|
|
context.fillStyle = "#ffffff";
|
|
context.fillRect(-26, -74, 52, 145);
|
|
colors.forEach((color, index) => {
|
|
const top = -71 + index * 21.6;
|
|
context.fillStyle = color;
|
|
context.fillRect(-23, top, 46, index === colors.length - 1 ? 37 - top : 21.6);
|
|
});
|
|
context.fillStyle = "#222222";
|
|
context.font = "700 5px Arial, sans-serif";
|
|
context.textAlign = "left";
|
|
context.textBaseline = "top";
|
|
context.fillText("COLOR", -20, 44);
|
|
context.fillStyle = "#979797";
|
|
context.fillRect(-20, 54, 24, 1);
|
|
context.fillStyle = "#ffffff";
|
|
for (let index = 0; index < 8; index += 1) {
|
|
const left = -26 + index * 6.5;
|
|
context.beginPath();
|
|
context.moveTo(left, 71);
|
|
context.lineTo(left + 3.25, 76);
|
|
context.lineTo(left + 6.5, 71);
|
|
context.closePath();
|
|
context.fill();
|
|
}
|
|
return;
|
|
}
|
|
if (element.style_id === "style_02") {
|
|
context.font = "4px Arial, sans-serif";
|
|
context.textAlign = "left";
|
|
context.textBaseline = "top";
|
|
context.fillStyle = "#222222";
|
|
context.fillText("C O L O R", -17, -77);
|
|
colors.forEach((color, index) => {
|
|
context.fillStyle = color;
|
|
context.fillRect(-18, -71 + index * 28, 36, 27);
|
|
});
|
|
return;
|
|
}
|
|
if (element.style_id === "style_08") {
|
|
colors.forEach((color, index) => {
|
|
const left = -73 + index * 29.2;
|
|
const center = -59 + index * 29;
|
|
context.fillStyle = color;
|
|
context.fillRect(left, -2, index === colors.length - 1 ? 73 - left : 29.2, 4);
|
|
context.beginPath();
|
|
context.arc(center, -7, 2, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.strokeStyle = "#ffffff";
|
|
context.lineWidth = 1;
|
|
context.stroke();
|
|
context.fillStyle = "#222222";
|
|
context.font = "4px Arial, sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillText(`0${index + 1}`, center, 8);
|
|
});
|
|
return;
|
|
}
|
|
context.fillStyle = "#ffffff";
|
|
context.beginPath();
|
|
context.moveTo(-78, -9);
|
|
for (let index = 0; index < 4; index += 1) {
|
|
const y = -9 + index * 4.5;
|
|
context.lineTo(-74, y + 2.25);
|
|
context.lineTo(-78, y + 4.5);
|
|
}
|
|
context.lineTo(-70, 9);
|
|
context.lineTo(-70, -9);
|
|
context.closePath();
|
|
context.fill();
|
|
context.fillStyle = "#222222";
|
|
context.fillRect(-72, -9, 17, 18);
|
|
context.fillStyle = "#ffffff";
|
|
context.font = "700 5px Arial, sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
context.fillText("C", -63.5, 0);
|
|
const stripColors = [...colors, "#f2f2f2"];
|
|
stripColors.forEach((color, index) => {
|
|
const left = -55 + index * (133 / stripColors.length);
|
|
context.fillStyle = color;
|
|
context.fillRect(left, -9, index === stripColors.length - 1 ? 78 - left : 133 / stripColors.length, 18);
|
|
});
|
|
}
|