feat: complete TASK-WP4-04 color and dynamic stickers
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import { MMCQ } from "@vibrant/quantizer-mmcq";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export interface ColorCardDefinition {
|
||||
cardId: "COLOR001" | "COLOR002" | "COLOR008" | "COLOR016";
|
||||
displayName: string;
|
||||
mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional";
|
||||
rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket";
|
||||
styleId: "style_01" | "style_02" | "style_08" | "style_16";
|
||||
}
|
||||
|
||||
export interface PaletteColor {
|
||||
hex: string;
|
||||
population: number;
|
||||
rgbValue: number;
|
||||
}
|
||||
|
||||
export const P0A_COLOR_CARDS: readonly ColorCardDefinition[] = [
|
||||
{ cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" },
|
||||
{ cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" },
|
||||
{ cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
|
||||
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
|
||||
] as const;
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user