feat: complete TASK-WP4-04 color and dynamic stickers

This commit is contained in:
suyx
2026-08-03 14:11:40 +08:00
parent dd7a23a281
commit 457e1147e4
29 changed files with 2234 additions and 61 deletions
+15
View File
@@ -0,0 +1,15 @@
export interface AmapAdapter {
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>;
}
export class MockAmapAdapter implements AmapAdapter {
readonly calls: Array<{ latitude: number; longitude: number }> = [];
async reverseGeocode(coordinates: { latitude: number; longitude: number }) {
this.calls.push({ ...coordinates });
return {
formattedValue: `模拟地点 ${coordinates.latitude.toFixed(4)}, ${coordinates.longitude.toFixed(4)}`,
serviceMode: "mock" as const,
};
}
}
+41
View File
@@ -93,6 +93,8 @@ import {
RecentAssetQuerySchema,
RecentAssetRecordRequestSchema,
RecentAssetRecordResponseSchema,
ReverseGeocodeRequestSchema,
ReverseGeocodeResponseSchema,
RegistrationCompleteHeadersSchema,
RegistrationCompleteRequestSchema,
RegistrationCompleteResponseSchema,
@@ -128,6 +130,7 @@ import {
type ProjectStateSaveHeaders,
type RecentAssetQuery,
type RecentAssetRecordRequest,
type ReverseGeocodeRequest,
type RegistrationCompleteRequest,
type RegistrationSendRequest,
} from "@dada/shared-contracts";
@@ -171,6 +174,7 @@ import {
} from "./registration-errors.js";
import type { RegistrationService } from "./registration.js";
import type { RecentAssetService } from "./recent-assets.js";
import type { AmapAdapter } from "./amap-adapter.js";
import { ModelConfigurationError } from "./model-configuration.js";
import type { ModelConfigurationService } from "./model-configuration.js";
@@ -187,6 +191,7 @@ const defaultBootstrap: BootstrapResponse = {
};
export interface CreateAppOptions {
amap?: AmapAdapter;
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
browserGate?: boolean;
browserSupportRelease?: BrowserSupportRelease;
@@ -742,6 +747,8 @@ export async function createApp(options: CreateAppOptions = {}) {
RecentAssetListResponseSchema,
RecentAssetRecordRequestSchema,
RecentAssetRecordResponseSchema,
ReverseGeocodeRequestSchema,
ReverseGeocodeResponseSchema,
FailedEmptyTrashRequestSchema,
FailedEmptyTrashResponseSchema,
ModelIdSchema,
@@ -896,6 +903,40 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
app.post(
"/api/v1/location/reverse-geocode",
{
attachValidation: true,
schema: {
body: Type.Ref(ReverseGeocodeRequestSchema),
headers: Type.Ref(CsrfHeadersSchema),
operationId: "reverseGeocodeLocation",
response: {
200: Type.Ref(ReverseGeocodeResponseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
503: Type.Null(),
},
tags: ["Location"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.amap) return reply.code(503).send(null);
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
try {
options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest);
return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const };
} catch (error) {
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
return reply.code(503).send(null);
}
},
);
app.post(
"/api/v1/admin-auth/login/send",
{
+2
View File
@@ -17,6 +17,7 @@ import { readSecureConfigCandidate } from "./secure-config.js";
import { StructuredJsonlLogger } from "./structured-log.js";
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
import { ModelConfigurationService } from "./model-configuration.js";
import { MockAmapAdapter } from "./amap-adapter.js";
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined;
@@ -70,6 +71,7 @@ if (credentialChannelEnabled) {
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
const app = await createApp({
amap: new MockAmapAdapter(),
...(browserSupportRelease ? { browserSupportRelease } : {}),
...(credits ? { credits } : {}),
...(latestExports ? { latestExports } : {}),
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
const previewHalfSize = {
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 },
} as const;
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = ref.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#30343b";
context.fillRect(0, 0, canvas.width, canvas.height);
const half = previewHalfSize[definition.styleId];
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
context.translate(canvas.width / 2, canvas.height / 2);
context.scale(scale, scale);
drawColorCard(context, createColorCardElement(definition, previewPalette, {
createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000001",
}, 0));
}, [definition]);
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
}
export function ColorCardPanel(props: { canAdd: boolean; hasBackground: boolean; onAdd: (definition: ColorCardDefinition) => void }) {
return <section className="editor-provider-panel">
<header><h2></h2><button aria-label="色卡说明" className="editor-info-button" title="色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化" type="button">i</button></header>
<p className="editor-provider-note"></p>
<div className="editor-provider-grid">
{P0A_COLOR_CARDS.map((definition) => <button
aria-label={`添加色卡 ${definition.cardId} ${definition.displayName}`}
disabled={!props.canAdd || !props.hasBackground}
key={definition.cardId}
onClick={() => props.onAdd(definition)}
type="button"
><ColorCardPreview definition={definition} /><strong>{definition.cardId}</strong><span>{definition.displayName}</span></button>)}
</div>
{!props.hasBackground ? <p className="editor-limit" role="status"></p> : null}
</section>;
}
+14
View File
@@ -0,0 +1,14 @@
import { useEffect, useState } from "react";
import type { CanvasState } from "@dada/shared-contracts";
type CanvasElement = CanvasState["elements"][number];
export function DynamicInspector(props: { element: CanvasElement; onCommit: (value: string) => void }) {
const [value, setValue] = useState(props.element.formatted_value ?? "");
useEffect(() => setValue(props.element.formatted_value ?? ""), [props.element.element_id, props.element.formatted_value]);
return <section className="editor-dynamic-inspector">
{props.element.template_or_asset_id === "DYN012" ? <p className="editor-font-substitution" role="note"> DIN_MediumAlternate.otf 使 FONT081 · Lexend Deca </p> : null}
<label><textarea aria-label="动态贴纸显示文字" onChange={(event) => setValue(event.target.value)} value={value} /></label>
<button className="editor-wide-command" disabled={!value.trim() || value === props.element.formatted_value} onClick={() => props.onCommit(value)} type="button"></button>
</section>;
}
+178
View File
@@ -0,0 +1,178 @@
import type { CanvasState } from "@dada/shared-contracts";
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
import type { CanvasElementIdentity } from "./editor-elements.js";
type CanvasElement = CanvasState["elements"][number];
export type DynamicCategory = "identity" | "location" | "other" | "time";
export type DynamicTemplateId = "DYN001" | "DYN002" | "DYN003" | "DYN004" | "DYN007" | "DYN008" | "DYN011" | "DYN012" | "DYN015" | "DYN016";
export interface DynamicStickerDefinition {
category: DynamicCategory;
displayName: string;
requiresLocationConsent: boolean;
templateId: DynamicTemplateId;
}
export interface DynamicProviderContext {
location?: { formattedValue: string; latitude?: number; longitude?: number };
now: Date;
profile: { creatorName: string; socialId: string };
}
// Declarative conversion of l_shijian7.prefab. The browser never executes the Android Prefab or Lua.
export const DYN012_RENDER_LAYOUT = {
background: "transparent",
divider: { color: "#FFFFFF", height: 42, width: 7, x: 0, y: 0 },
hour: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: -60, y: 0 },
meridiem: { color: "#FFFFFF", fontId: "FONT081", fontSize: 20, x: 94, y: -65 },
minute: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: 60, y: 0 },
} as const;
export function dyn012DisplayParts(element: CanvasElement) {
if (element.type !== "dynamic_sticker" || element.template_or_asset_id !== "DYN012") throw new Error("dyn012_element_required");
const formatted = element.formatted_value?.match(/^(\d{1,2}):(\d{2})$/);
const hour = formatted?.[1]?.padStart(2, "0") ?? String(element.dynamic_fields?.hour ?? "00").padStart(2, "0");
const minute = formatted?.[2] ?? String(element.dynamic_fields?.minute ?? "00").padStart(2, "0");
const hourNumber = Number(hour);
const minuteNumber = Number(minute);
return {
hour,
meridiem: hourNumber > 12 || (hourNumber === 12 && minuteNumber > 0) ? "PM" : "AM",
minute,
} as const;
}
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = [
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
] as const;
function twoDigits(value: number) {
return String(value).padStart(2, "0");
}
export function normalizeSocialId(value: string) {
return `@${value.trim().replace(/^@+/, "")}`;
}
function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderContext) {
const year = String(context.now.getFullYear());
const month = twoDigits(context.now.getMonth() + 1);
const day = twoDigits(context.now.getDate());
const hour = twoDigits(context.now.getHours());
const minute = twoDigits(context.now.getMinutes());
if (templateId === "DYN001") return { fields: { title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "输入地点" };
if (templateId === "DYN002") return { fields: { city_en: context.location?.formattedValue ?? "LOCATION", title: context.location?.formattedValue ?? "输入地点" }, value: (context.location?.formattedValue ?? "LOCATION").toUpperCase() };
if (templateId === "DYN003") return { fields: { city: context.location?.formattedValue ?? "城市", title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "城市 · 输入地点" };
if (templateId === "DYN004") {
if (context.location?.latitude === undefined || context.location.longitude === undefined) throw new Error("dynamic_location_consent_required");
return {
fields: { city: context.location.formattedValue, latitude: context.location.latitude, longitude: context.location.longitude },
value: `${context.location.formattedValue}\n${context.location.latitude.toFixed(4)}, ${context.location.longitude.toFixed(4)}`,
};
}
if (templateId === "DYN007") {
const nickname = normalizeSocialId(context.profile.socialId);
return { fields: { nickname }, value: nickname };
}
if (templateId === "DYN008") return { fields: { hour, minute, month }, value: `${month}月 · ${hour}:${minute}` };
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
}
export function createDynamicStickerElement(
templateId: DynamicTemplateId,
context: DynamicProviderContext,
identity: CanvasElementIdentity,
zIndex: number,
): CanvasElement {
if (!P0A_DYNAMIC_STICKERS.some((item) => item.templateId === templateId)) throw new Error("dynamic_template_unavailable");
const snapshot = snapshotFor(templateId, context);
return {
created_at: identity.createdAt,
dynamic_fields: snapshot.fields,
element_id: identity.elementId,
...(templateId === "DYN004" && context.location?.latitude !== undefined && context.location.longitude !== undefined
? { coordinates: { latitude: context.location.latitude, longitude: context.location.longitude } }
: {}),
...(templateId === "DYN012" ? { font_override: "FONT081" } : {}),
formatted_value: snapshot.value,
opacity: 1,
position: { x: 0.5, y: 0.5 },
resource_version: DYNAMIC_RESOURCE_VERSION,
rotation: 0,
scale: { x: 1, y: 1 },
style_parameters: {
provider: "typescript-declarative-v1",
...(templateId === "DYN012" ? { font_substitution_disclosure: "DIN_MediumAlternate.otf missing; FONT081 Lexend Deca substitute" } : {}),
},
template_or_asset_id: templateId,
type: "dynamic_sticker",
z_index: zIndex,
};
}
export function overrideDynamicStickerValue(element: CanvasElement, formattedValue: string): CanvasElement {
if (element.type !== "dynamic_sticker") throw new Error("dynamic_element_required");
const value = formattedValue.trim();
if (!value) throw new Error("dynamic_value_required");
return {
...structuredClone(element),
dynamic_fields: { ...structuredClone(element.dynamic_fields ?? {}), display_override: value },
formatted_value: value,
};
}
export interface LocationResult {
formattedValue: string;
latitude: number;
longitude: number;
}
export class LocationConsentGate {
constructor(private readonly adapter: {
geolocate: () => Promise<{ latitude: number; longitude: number }>;
reverseGeocode: (coordinates: { latitude: number; longitude: number }) => Promise<string>;
}) {}
reject() {
return undefined;
}
async confirm(): Promise<LocationResult> {
const coordinates = await this.adapter.geolocate();
if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90
|| !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) {
throw new Error("dynamic_location_invalid");
}
const formattedValue = (await this.adapter.reverseGeocode(coordinates)).trim();
if (!formattedValue) throw new Error("dynamic_location_unavailable");
return { ...coordinates, formattedValue };
}
}
export function browserGeolocate() {
return new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error("dynamic_location_unsupported"));
return;
}
navigator.geolocation.getCurrentPosition(
(position) => resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude }),
() => reject(new Error("dynamic_location_denied")),
{ enableHighAccuracy: false, maximumAge: 0, timeout: 10_000 },
);
});
}
+196
View File
@@ -0,0 +1,196 @@
import type { CanvasState } from "@dada/shared-contracts";
import { fontOption, type FontOption } from "./text-assets.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
type CanvasElement = CanvasState["elements"][number];
export type DynamicTextValue =
| "city"
| "city_en"
| "day"
| "hour"
| "latitude_dms"
| "longitude_dms"
| "meridiem"
| "minute"
| "month"
| "month_en"
| "nickname"
| "time"
| "title"
| "year_short";
export interface DynamicImageLayer {
assetId: string;
height: number;
width: number;
x: number;
y: number;
}
export interface DynamicTextLayer {
align: "center" | "left";
color: string;
fontId: string;
fontSize: number;
value: DynamicTextValue;
x: number;
y: number;
}
export interface DynamicRenderModel {
halfSize: { height: number; width: number };
imageLayers: readonly DynamicImageLayer[];
sourceCandidateId: string;
textLayers: readonly DynamicTextLayer[];
}
export const DYNAMIC_RESOURCE_VERSION = "wp4-dynamic-source-v1";
const dynamicFont = (fontId: string): FontOption => ({
displayName: fontId,
fontId,
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
});
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = [
dynamicFont("15974853bc3294ef68e7e6d58fe74fd7"),
dynamicFont("46f8336813e4c48d06a1aef294fdccf6"),
dynamicFont("53ca6b704728520da50c145eabb2e635"),
dynamicFont("cca5efc0e02fb1bf62349bd68ef30fc1"),
dynamicFont("dd25b35dcb7ba4476cbaa9a9592e39e2"),
dynamicFont("e4210c9872f0c279b35273f230809821"),
dynamicFont("f4bfd4132df2d6be97ceabadf3853505"),
] as const;
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
DYN001: {
halfSize: { height: 42, width: 130 },
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
sourceCandidateId: "l_POI01",
textLayers: [{ align: "center", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 21, value: "title", x: 0, y: 0 }],
},
DYN002: {
halfSize: { height: 48, width: 215 },
imageLayers: [{ assetId: "DYN002-image29", height: 77, width: 232, x: 94.5, y: 0.5 }],
sourceCandidateId: "l_POI02",
textLayers: [
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 19, value: "title", x: -94.5, y: 10.5 },
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 10, value: "city_en", x: 0, y: 36 },
],
},
DYN003: {
halfSize: { height: 54, width: 215 },
imageLayers: [{ assetId: "DYN003-image30", height: 85, width: 235, x: -93.857, y: -23.635 }],
sourceCandidateId: "l_POI03",
textLayers: [
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 18, value: "title", x: -54, y: 9 },
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 24, value: "city", x: -53, y: -16 },
],
},
DYN004: {
halfSize: { height: 48, width: 180 },
imageLayers: [{ assetId: "DYN004-image32", height: 76, width: 39, x: -21.067, y: -1 }],
sourceCandidateId: "l_POI04",
textLayers: [
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 26, value: "city", x: -28, y: 0 },
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "latitude_dms", x: -29.844, y: 36 },
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "longitude_dms", x: 71.523, y: 36 },
],
},
DYN007: {
halfSize: { height: 34, width: 190 },
imageLayers: [],
sourceCandidateId: "diaoyu",
textLayers: [{ align: "center", color: "#295E8B", fontId: "53ca6b704728520da50c145eabb2e635", fontSize: 50, value: "nickname", x: 0, y: 0 }],
},
DYN008: {
halfSize: { height: 180, width: 140 },
imageLayers: [{ assetId: "DYN008-backendui0", height: 185, width: 5, x: -125, y: 0 }],
sourceCandidateId: "l_shijian2",
textLayers: [
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 70, value: "month_en", x: -112, y: -32 },
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 130, value: "time", x: -114.024, y: 97 },
],
},
DYN011: {
halfSize: { height: 40, width: 130 },
imageLayers: [{ assetId: "DYN011-backendui0", height: 12, width: 8, x: -120, y: -19 }],
sourceCandidateId: "l_shijian6",
textLayers: [
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "year_short", x: -85, y: 0 },
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "month", x: 0, y: 0 },
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "day", x: 85, y: 0 },
],
},
DYN012: {
halfSize: { height: 78, width: 118 },
imageLayers: [],
sourceCandidateId: "l_shijian7",
textLayers: [],
},
DYN015: {
halfSize: { height: 42, width: 300 },
imageLayers: [{ assetId: "DYN015-imager2", height: 60, width: 60, x: -140, y: 0 }],
sourceCandidateId: "0721userna",
textLayers: [{ align: "left", color: "#666666", fontId: "cca5efc0e02fb1bf62349bd68ef30fc1", fontSize: 40, value: "nickname", x: -100, y: 0 }],
},
DYN016: {
halfSize: { height: 48, width: 160 },
imageLayers: [{ assetId: "DYN016-image21", height: 59, width: 186, x: -61.842, y: 16.483 }],
sourceCandidateId: "l_username00",
textLayers: [{ align: "left", color: "#FFFFFF", fontId: "46f8336813e4c48d06a1aef294fdccf6", fontSize: 27, value: "nickname", x: 0, y: 0 }],
},
};
export function dynamicFontOptionsFor(templateId: string) {
if (templateId === "DYN012") {
const replacement = fontOption("FONT081");
return replacement ? [replacement] : [];
}
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
if (!model) return [];
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
return DYNAMIC_FONT_OPTIONS.filter((option) => ids.has(option.fontId));
}
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
return `/api/v1/assets/public/${encodeURIComponent(resourceVersion)}/${encodeURIComponent(assetId)}`;
}
function numericField(element: CanvasElement, key: string) {
const value = element.dynamic_fields?.[key];
return typeof value === "number" ? value : Number(value);
}
function coordinate(value: number, positive: string, negative: string) {
if (!Number.isFinite(value)) return "";
const absolute = Math.abs(value);
const degrees = Math.floor(absolute);
const minutesFloat = (absolute - degrees) * 60;
const minutes = Math.floor(minutesFloat);
const seconds = (minutesFloat - minutes) * 60;
return `${degrees}°${String(minutes).padStart(2, "0")}'${seconds.toFixed(2).padStart(5, "0")}\"${value >= 0 ? positive : negative}`;
}
export function dynamicTextValue(element: CanvasElement, value: DynamicTextValue) {
const fields = element.dynamic_fields ?? {};
const text = (key: string) => String(fields[key] ?? "");
const override = fields.display_override;
if (typeof override === "string" && (value === "nickname" || value === "title")) return element.formatted_value ?? override;
if (value === "time") return `${text("hour")}:${text("minute")}`;
if (value === "month_en") {
const month = Math.max(1, Math.min(12, numericField(element, "month")));
return `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][month - 1]}.`;
}
if (value === "year_short") return text("year").slice(-2);
if (value === "latitude_dms") return coordinate(numericField(element, "latitude"), "N", "S");
if (value === "longitude_dms") return coordinate(numericField(element, "longitude"), "E", "W");
if (value === "meridiem") {
const hour = numericField(element, "hour");
const minute = numericField(element, "minute");
return hour > 12 || (hour === 12 && minute > 0) ? "PM" : "AM";
}
return text(value);
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef } from "react";
import { createDynamicStickerElement, P0A_DYNAMIC_STICKERS, type DynamicTemplateId } from "./dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS, dynamicImageUrl } from "./dynamic-render-models.js";
import { drawDynamicSticker } from "./editor-stage.js";
import type { ArchivedFontStatus } from "./text-font-loader.js";
const categoryLabels = { identity: "身份", location: "地点", other: "综合", time: "时间" } as const;
function loadImage(url: string) {
return new Promise<HTMLImageElement | undefined>((resolve) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => resolve(undefined);
image.src = url;
});
}
function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; templateId: DynamicTemplateId }) {
const ref = useRef<HTMLCanvasElement>(null);
const statusKey = Object.entries(props.fontStatuses).map(([id, status]) => `${id}:${status}`).join("|");
useEffect(() => {
let active = true;
const model = DYNAMIC_RENDER_MODELS[props.templateId];
const element = createDynamicStickerElement(props.templateId, {
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
}, { createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000002" }, 0);
void Promise.all(model.imageLayers.map(async (layer) => [layer.assetId, await loadImage(dynamicImageUrl(element.resource_version, layer.assetId))] as const)).then((entries) => {
if (!active) return;
const canvas = ref.current;
const context = canvas?.getContext("2d");
if (!canvas || !context) return;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#30343b";
context.fillRect(0, 0, canvas.width, canvas.height);
const scale = Math.min(1, 146 / (model.halfSize.width * 2), 62 / (model.halfSize.height * 2));
context.translate(canvas.width / 2, canvas.height / 2);
context.scale(scale, scale);
const images = Object.fromEntries(entries.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
drawDynamicSticker(context, element, props.fontStatuses, images);
});
return () => { active = false; };
}, [props.templateId, statusKey]);
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
}
export function DynamicStickerPanel(props: { canAdd: boolean; fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; onAdd: (templateId: DynamicTemplateId) => void }) {
return <section className="editor-provider-panel">
<h2></h2>
<div className="editor-provider-groups">
{(["location", "time", "identity", "other"] as const).map((category) => <section key={category}>
<h3>{categoryLabels[category]}</h3>
<div className="editor-provider-grid">
{P0A_DYNAMIC_STICKERS.filter((item) => item.category === category).map((item) => <button
aria-label={`添加动态贴纸 ${item.templateId} ${item.displayName}`}
disabled={!props.canAdd}
key={item.templateId}
onClick={() => props.onAdd(item.templateId)}
type="button"
><DynamicPreview fontStatuses={props.fontStatuses} templateId={item.templateId} /><strong>{item.templateId}</strong><span>{item.displayName}</span>{item.templateId === "DYN012" ? <small>FONT081 </small> : null}</button>)}
</div>
</section>)}
</div>
</section>;
}
+19
View File
@@ -1,5 +1,8 @@
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
type CanvasElement = CanvasState["elements"][number];
export interface CanvasPoint {
@@ -45,6 +48,22 @@ function textLineUnits(line: string) {
}
export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint {
if (element.type === "color_card") {
const vertical = element.style_id === "style_01" || element.style_id === "style_02";
return {
x: Math.max(hitHalfExtent, (vertical ? 62 : 175) / state.pixel_width) * element.scale.x,
y: Math.max(hitHalfExtent, (vertical ? 155 : 50) / state.pixel_height) * element.scale.y,
};
}
if (element.type === "dynamic_sticker") {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
const halfWidth = model?.halfSize.width ?? 170;
const halfHeight = model?.halfSize.height ?? 62;
return {
x: Math.max(hitHalfExtent, halfWidth / state.pixel_width) * element.scale.x,
y: Math.max(hitHalfExtent, halfHeight / state.pixel_height) * element.scale.y,
};
}
if (element.type !== "text_template") {
return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y };
}
+49 -2
View File
@@ -176,8 +176,7 @@
.editor-sticker-grid button:disabled { color: #62625d; background: #e8e8e5; cursor: not-allowed; }
.editor-sticker-grid button > span:last-child { overflow-wrap: anywhere; font-size: 11px; }
.editor-sticker-preview { display: block; width: 58px; height: 58px; border: 3px solid #111111; background: #f2f400; }
.editor-sticker-preview.stk002 { border-radius: 50%; background: #1769aa; }
.editor-sticker-preview { display: block; width: 58px; height: 58px; object-fit: contain; border: 1px solid #b9b9b3; background: #30343b; }
.editor-limit { margin: 12px 0 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 12px; }
.editor-workspace {
@@ -368,6 +367,46 @@
.editor-template-mark.tag { background: #dbeafe; }
.editor-template-mark.simple { background: #ffffff; }
.editor-provider-panel > header { display: flex; align-items: center; justify-content: space-between; }
.editor-provider-panel > header h2 { margin: 0; }
.editor-info-button { width: 28px; height: 28px; border: 1px solid #111111; border-radius: 50%; background: #ffffff; font: 700 13px Georgia, serif; }
.editor-provider-note { margin: 8px 0 12px; color: #62625d; font-size: 11px; }
.editor-provider-groups { display: grid; gap: 18px; }
.editor-provider-groups section { display: grid; gap: 8px; }
.editor-provider-groups h3 { margin: 0; font-size: 12px; }
.editor-provider-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
.editor-provider-grid button {
display: grid;
min-width: 0;
min-height: 112px;
grid-template-rows: 48px 16px minmax(18px, auto) auto;
align-items: center;
padding: 7px;
overflow: hidden;
border: 1px solid #85857f;
border-radius: 0;
background: #ffffff;
color: #111111;
font: inherit;
font-size: 10px;
text-align: left;
}
.editor-provider-grid button:disabled { background: #e8e8e5; color: #62625d; cursor: not-allowed; }
.editor-provider-grid strong { font-family: Consolas, monospace; font-size: 10px; }
.editor-provider-grid small { color: #8f1d14; font-size: 9px; }
.editor-color-card-preview { display: flex; width: 100%; height: 42px; align-items: stretch; border: 1px solid #111111; background: #ffffff; }
.editor-color-card-preview i { flex: 1; }
.editor-color-card-preview.style_01,
.editor-color-card-preview.style_02 { width: 30px; height: 48px; flex-direction: column; justify-self: center; }
.editor-dynamic-preview { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #ffffff; font-size: 15px; font-weight: 800; }
.editor-dynamic-preview.location { border-left: 6px solid #1769aa; }
.editor-dynamic-preview.time { background: #FFE62C; }
.editor-dynamic-preview.identity { background: #1769aa; color: #ffffff; }
.editor-dynamic-preview.dyn012 { position: relative; display: flex; gap: 7px; justify-content: center; border-color: #111111; background: #30343b; color: #ffffff; font-family: Dada_FONT081, sans-serif; font-size: 17px; font-weight: 400; }
.editor-dynamic-preview.dyn012 i { width: 2px; height: 15px; background: #ffffff; }
.editor-dynamic-preview.dyn012 small { position: absolute; top: 3px; right: 6px; color: #ffffff; font-size: 6px; }
.editor-source-preview-canvas { display: block; width: 100%; height: 72px; background: #30343b; }
.editor-text-inspector { display: grid; gap: 14px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
.editor-text-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
.editor-text-inspector textarea,
@@ -385,6 +424,11 @@
.editor-color-swatches button { width: 32px; height: 32px; border: 1px solid #111111; border-radius: 0; }
.editor-check { display: flex !important; grid-template-columns: 18px 1fr; align-items: center; }
.editor-check input { width: 18px; height: 18px; margin: 0; }
.editor-dynamic-inspector { display: grid; gap: 10px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
.editor-dynamic-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
.editor-dynamic-inspector textarea { min-height: 82px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; resize: vertical; font: inherit; }
.editor-dynamic-inspector .editor-wide-command { margin-top: 0; }
.editor-font-substitution { margin: 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 11px; }
.editor-statusbar {
display: flex;
@@ -415,6 +459,9 @@
.editor-confirm p { margin: 0; }
.editor-confirm > div { display: flex; gap: 8px; margin-top: 24px; }
.editor-confirm button { min-height: 40px; padding: 8px 14px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 700; }
.editor-location-consent label { display: grid; gap: 6px; margin-top: 18px; font-size: 12px; font-weight: 700; }
.editor-location-consent input { min-height: 40px; padding: 7px 9px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
.editor-location-actions { display: grid !important; grid-template-columns: 1fr; }
.editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; }
+161 -24
View File
@@ -25,12 +25,29 @@ import {
type TextTemplateDefinition,
} from "./text-assets.js";
import { TextTemplatePanel } from "./text-template-panel.js";
import { ColorCardPanel } from "./color-card-panel.js";
import { DynamicInspector } from "./dynamic-inspector.js";
import { DynamicStickerPanel } from "./dynamic-sticker-panel.js";
import {
LocationConsentGate,
P0A_DYNAMIC_STICKERS,
browserGeolocate,
createDynamicStickerElement,
overrideDynamicStickerValue,
type DynamicTemplateId,
} from "./dynamic-provider.js";
import { dynamicFontOptionsFor } from "./dynamic-render-models.js";
import {
createColorCardElement,
extractPaletteFromImage,
type ColorCardDefinition,
} from "./palette-provider.js";
import "./editor-page.css";
type Ratio = CanvasState["ratio"];
type CanvasElement = CanvasState["elements"][number];
type EditorAssetPanel = "background" | "history" | "stickers" | "text";
type EditorAssetPanel = "background" | "color" | "dynamic" | "history" | "stickers" | "text";
interface TextEditState {
draft: CanvasElement;
@@ -40,7 +57,13 @@ interface TextEditState {
interface EditorSession {
csrf_token: string;
user: { creator_name: string; user_id: string };
user: { creator_name: string; social_id: string; user_id: string };
}
interface LocationDialogState {
error?: string;
manualValue: string;
pending: boolean;
}
interface EditorProject {
@@ -70,14 +93,9 @@ function formatDate(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}
function paletteForAsset(assetId: string) {
const seed = assetId.replaceAll("-", "");
return [0, 1, 2, 3, 4].map((index) => `#${seed.slice(index * 6, index * 6 + 6).padEnd(6, "0")}`);
}
const stickerFixtures = [
{ assetId: "STK001", label: "方形标记" },
{ assetId: "STK002", label: "圆形标记" },
{ assetId: "STK001", label: "part1 原版草莓" },
{ assetId: "STK002", label: "part2 原版小狗" },
] as const;
function newElementIdentity(): CanvasElementIdentity {
@@ -106,6 +124,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const [recentTextIds, setRecentTextIds] = useState<string[]>([]);
const [fontStatuses, setFontStatuses] = useState<Record<string, ArchivedFontStatus>>({});
const [textEdit, setTextEdit] = useState<TextEditState>();
const [locationDialog, setLocationDialog] = useState<LocationDialogState>();
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
@@ -170,14 +189,19 @@ export function EditorPage({ projectId }: { projectId: string }) {
useEffect(() => {
if (!canvasState) return;
for (const element of canvasState.elements) {
if (element.type !== "text_template") continue;
const fontId = fontIdForTextElement(element);
const option = fontId ? fontOption(fontId) : undefined;
if (fontId && option) void ensureFont(option.fontId, option.url);
else if (fontId) setFontStatuses((current) => ({ ...current, [fontId]: "unavailable" }));
const options = element.type === "text_template"
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
for (const option of options) void ensureFont(option.fontId, option.url);
}
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
useEffect(() => {
if (activePanel !== "dynamic") return;
const options = P0A_DYNAMIC_STICKERS.flatMap((definition) => dynamicFontOptionsFor(definition.templateId));
for (const option of options) void ensureFont(option.fontId, option.url);
}, [activePanel]);
useEffect(() => {
if (!canvasState || selectedIds.length !== 1) {
setTextEdit(undefined);
@@ -250,11 +274,26 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
}
function confirmBackground() {
async function paletteForAsset(assetId: string) {
const image = new Image();
const loaded = new Promise<HTMLImageElement>((resolve, reject) => {
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("palette_image_unavailable"));
});
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(assetId)}`;
return extractPaletteFromImage(await loaded);
}
async function confirmBackground() {
if (!canvasState || !pendingBackground) return;
commitCanvas(switchBackground(canvasState, pendingBackground, paletteForAsset(pendingBackground)));
setPendingBackground(undefined);
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
try {
const palette = await paletteForAsset(pendingBackground);
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
setPendingBackground(undefined);
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
} catch {
setNotice("新底图无法读取,未更换底图或刷新色卡");
}
}
function controllerForCurrent() {
@@ -291,6 +330,92 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
}
async function addColorCard(definition: ColorCardDefinition) {
const assetId = canvasState?.background.asset_id;
const controller = controllerForCurrent();
if (!assetId || !canvasState || !controller) return;
try {
const palette = await paletteForAsset(assetId);
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "色卡已按原始底图加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
}
}
async function addDynamicSticker(templateId: DynamicTemplateId, location?: { formattedValue: string; latitude?: number; longitude?: number }) {
const controller = controllerForCurrent();
if (!canvasState || !controller || !session) return;
if (templateId === "DYN012") {
const font = fontOption("FONT081");
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
return;
}
}
try {
controller.add(createDynamicStickerElement(templateId, {
...(location ? { location } : {}),
now: new Date(),
profile: { creatorName: session.user.creator_name, socialId: session.user.social_id },
}, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "动态值已确认并加入画布");
setLocationDialog(undefined);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("动态贴纸未能加入画布");
}
}
function chooseDynamicSticker(templateId: DynamicTemplateId) {
if (templateId === "DYN004") {
setLocationDialog({ manualValue: "", pending: false });
return;
}
void addDynamicSticker(templateId);
}
async function confirmAutomaticLocation() {
if (!session) return;
setLocationDialog((current) => {
if (!current) return current;
const { error: _error, ...rest } = current;
return { ...rest, pending: true };
});
const gate = new LocationConsentGate({
geolocate: browserGeolocate,
reverseGeocode: async (coordinates) => {
const response = await fetch("/api/v1/location/reverse-geocode", {
body: JSON.stringify(coordinates),
credentials: "same-origin",
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
method: "POST",
});
if (!response.ok) throw new Error("dynamic_location_service_unavailable");
const payload = await response.json() as { formatted_value: string };
return payload.formatted_value;
},
});
try {
const result = await gate.confirm();
await addDynamicSticker("DYN004", result);
} catch {
setLocationDialog((current) => current ? { ...current, error: "自动定位不可用,请改用手动地点贴纸。", pending: false } : current);
}
}
function commitDynamicOverride(element: CanvasElement, value: string) {
const controller = controllerForCurrent();
if (!controller) return;
try {
controller.replaceElement(overrideDynamicStickerValue(element, value));
commitElementOperation(controller, "动态贴纸显示文字已更新");
} catch {
setNotice("动态贴纸显示文字不能为空");
}
}
async function recordRecentTextTemplate(templateId: string, resourceVersion: string) {
if (!session) return;
try {
@@ -527,7 +652,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted";
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker")
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type !== "text_template")
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
: undefined;
return (
@@ -552,9 +677,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
{ label: "历史", panel: "history" as const },
{ label: "文字模板", panel: "text" as const },
{ label: "普通贴纸", panel: "stickers" as const },
{ label: "色卡" },
{ label: "动态贴纸" },
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} disabled={!item.panel} key={item.label} onClick={() => { if (item.panel) setActivePanel(item.panel); }} type="button">{item.label}</button>)}
{ label: "色卡", panel: "color" as const },
{ label: "动态贴纸", panel: "dynamic" as const },
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} key={item.label} onClick={() => setActivePanel(item.panel)} type="button">{item.label}</button>)}
</nav>
{activePanel === "background" ? <section><h2></h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span></span></button></section> : null}
{activePanel === "history" ? <section><h2></h2><div className="editor-history-list">
@@ -573,8 +698,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
{...(templateCategory ? { category: templateCategory } : {})}
/> : null}
{activePanel === "stickers" ? <section><h2></h2><div className="editor-sticker-grid">
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><span className={`editor-sticker-preview ${sticker.assetId.toLowerCase()}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><img alt="" className="editor-sticker-preview" src={`/api/v1/assets/public/fixture-v1/${sticker.assetId}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status"> 50 </p> : null}</section> : null}
{activePanel === "color" ? <ColorCardPanel canAdd={canEdit && canvasState.elements.length < 50} hasBackground={Boolean(canvasState.background.asset_id)} onAdd={(definition) => { void addColorCard(definition); }} /> : null}
{activePanel === "dynamic" ? <DynamicStickerPanel canAdd={canEdit && canvasState.elements.length < 50} fontStatuses={fontStatuses} onAdd={chooseDynamicSticker} /> : null}
</aside>
<section aria-label="画布工作区" className="editor-workspace">
<div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
@@ -630,6 +757,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
onTemplate={(templateId) => { void changeTextTemplate(templateId); }}
templates={P0A_TEXT_TEMPLATES}
/> : <p className="editor-muted"></p> : null}
{selectedElements.length === 1 && selectedElements[0]?.type === "dynamic_sticker" && canEdit
? <DynamicInspector element={selectedElements[0]} onCommit={(value) => commitDynamicOverride(selectedElements[0]!, value)} />
: null}
<div className="editor-object-moves" role="group" aria-label="移动对象">
<button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button"></button>
<button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button"></button>
@@ -657,7 +787,14 @@ export function EditorPage({ projectId }: { projectId: string }) {
</aside>
</main>
<footer className="editor-statusbar"><span> {canvasState.pixel_width} × {canvasState.pixel_height}</span><span> {canvasState.elements.length} / 50</span><span> 100%</span><span> · state version {project.state_version}</span></footer>
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm"></h2><p></p><div><button className="editor-primary" onClick={confirmBackground} type="button"></button><button onClick={() => setPendingBackground(undefined)} type="button"></button></div></section></div> : null}
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm"></h2><p></p><div><button className="editor-primary" onClick={() => { void confirmBackground(); }} type="button"></button><button onClick={() => setPendingBackground(undefined)} type="button"></button></div></section></div> : null}
{locationDialog ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-location-consent" aria-modal="true" className="editor-confirm editor-location-consent" role="dialog">
<p>LOCATION PRIVACY</p><h2 id="editor-location-consent">使</h2>
<p></p>
{locationDialog.error ? <p className="editor-limit" role="alert">{locationDialog.error}</p> : null}
<label><input aria-label="手动地点文字" disabled={locationDialog.pending} onChange={(event) => setLocationDialog((current) => current ? { ...current, manualValue: event.target.value } : current)} value={locationDialog.manualValue} /></label>
<div className="editor-location-actions"><button className="editor-primary" disabled={locationDialog.pending} onClick={() => { void confirmAutomaticLocation(); }} type="button">{locationDialog.pending ? "正在定位" : "同意并自动定位"}</button><button disabled={!locationDialog.manualValue.trim() || locationDialog.pending} onClick={() => { void addDynamicSticker("DYN001", { formattedValue: locationDialog.manualValue.trim() }); }} type="button"></button><button disabled={locationDialog.pending} onClick={() => setLocationDialog(undefined)} type="button"></button></div>
</section></div> : null}
</div>
);
}
+118 -29
View File
@@ -2,9 +2,13 @@ import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } fr
import type { CanvasState } from "@dada/shared-contracts";
import { cssFilterForBackground } from "./editor-canvas.js";
import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
import { fontIdForTextElement } from "./text-assets.js";
import { drawColorCard } from "./palette-provider.js";
interface Gesture {
append: boolean;
@@ -116,11 +120,77 @@ function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState
});
}
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
context.fillStyle = "#e5e7eb";
context.fillRect(-140, -42, 280, 84);
context.fillStyle = "#9f1d1d";
context.font = "600 18px Microsoft YaHei UI, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(message, 0, 0);
}
export function drawDynamicSticker(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const id = element.template_or_asset_id as DynamicTemplateId;
const model = DYNAMIC_RENDER_MODELS[id];
if (!model) {
drawDynamicUnavailable(context, "动态贴纸模型不可用");
return;
}
if (dynamicFontOptionsFor(id).some((font) => fontStatuses[font.fontId] !== "ready")) {
drawDynamicUnavailable(context, id === "DYN012" ? "FONT081 替代字体不可用" : "原版字体不可用");
return;
}
if (model.imageLayers.some((layer) => !resourceImages[layer.assetId])) {
drawDynamicUnavailable(context, "原版图片资源不可用");
return;
}
context.textBaseline = "middle";
for (const layer of model.imageLayers) {
const image = resourceImages[layer.assetId]!;
context.drawImage(image, layer.x - layer.width / 2, layer.y - layer.height / 2, layer.width, layer.height);
}
if (id === "DYN012") {
const parts = dyn012DisplayParts(element);
const layout = DYN012_RENDER_LAYOUT;
context.fillStyle = layout.divider.color;
context.fillRect(layout.divider.x - layout.divider.width / 2, layout.divider.y - layout.divider.height / 2, layout.divider.width, layout.divider.height);
for (const [part, layer] of [[parts.hour, layout.hour], [parts.minute, layout.minute], [parts.meridiem, layout.meridiem]] as const) {
context.fillStyle = layer.color;
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
context.textAlign = "center";
context.fillText(part, layer.x, layer.y);
}
return;
}
for (const layer of model.textLayers) {
context.fillStyle = layer.color;
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
context.textAlign = layer.align;
context.fillText(dynamicTextValue(element, layer.value), layer.x, layer.y);
}
}
function elementSelectionHalfSize(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
) {
if (element.type === "color_card") {
if (element.style_id === "style_01") return { height: 76 * element.scale.y, width: 26 * element.scale.x };
if (element.style_id === "style_02") return { height: 77 * element.scale.y, width: 18 * element.scale.x };
if (element.style_id === "style_08") return { height: 10 * element.scale.y, width: 73 * element.scale.x };
return { height: 9 * element.scale.y, width: 78 * element.scale.x };
}
if (element.type === "dynamic_sticker") {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
}
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
@@ -130,7 +200,14 @@ function elementSelectionHalfSize(
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
}
function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number, fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
function drawElement(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
width: number,
height: number,
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const x = element.position.x * width;
const y = element.position.y * height;
context.save();
@@ -139,28 +216,30 @@ function drawElement(context: CanvasRenderingContext2D, element: CanvasState["el
context.rotate((element.rotation * Math.PI) / 180);
const flip = element.style_parameters?.flip_horizontal === true ? -1 : 1;
context.scale(element.scale.x * flip, element.scale.y);
if (element.type === "color_card") {
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
colors.forEach((color, index) => {
context.fillStyle = color;
context.fillRect(index * 48 - 120, -28, 44, 56);
});
} else if (element.type === "static_sticker") {
context.fillStyle = element.template_or_asset_id.endsWith("2") ? "#1769aa" : "#f2f400";
context.strokeStyle = "#111111";
context.lineWidth = 5;
context.beginPath();
context.roundRect(-62, -62, 124, 124, element.template_or_asset_id.endsWith("2") ? 62 : 8);
context.fill();
context.stroke();
context.fillStyle = "#111111";
context.font = "700 22px Consolas, monospace";
context.textAlign = "center";
context.fillText(element.template_or_asset_id, 0, 8);
if (element.type === "color_card") drawColorCard(context, element);
else if (element.type === "static_sticker") {
const image = resourceImages[element.template_or_asset_id];
if (!image) drawDynamicUnavailable(context, "原版贴纸资源不可用");
else {
const scale = 156 / Math.max(image.naturalWidth, image.naturalHeight);
const width = image.naturalWidth * scale;
const height = image.naturalHeight * scale;
context.drawImage(image, -width / 2, -height / 2, width, height);
}
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
context.restore();
}
function loadCanvasImage(url: string) {
return new Promise<HTMLImageElement | undefined>((resolve) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => resolve(undefined);
image.src = url;
});
}
export function EditorStage(props: EditorStageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const gestureRef = useRef<Gesture | undefined>(undefined);
@@ -168,20 +247,21 @@ export function EditorStage(props: EditorStageProps) {
const [marquee, setMarquee] = useState<CanvasRect>();
useEffect(() => {
let active = true;
const canvas = canvasRef.current;
if (!canvas) return undefined;
canvas.width = props.canvasState.pixel_width;
canvas.height = props.canvasState.pixel_height;
const context = canvas.getContext("2d");
if (!context) return undefined;
const render = (image?: HTMLImageElement) => {
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvas.width, canvas.height);
context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
context.filter = "none";
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height, props.fontStatuses);
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height, props.fontStatuses, resourceImages);
context.lineWidth = 4;
context.strokeStyle = "#005fcc";
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
@@ -201,15 +281,24 @@ export function EditorStage(props: EditorStageProps) {
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
context.restore();
};
if (!props.assetId) {
render();
return undefined;
const background = props.assetId
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`)
: Promise.resolve(undefined);
const imageReferences = new Map<string, string>();
for (const element of props.canvasState.elements) {
if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id));
if (element.type === "dynamic_sticker") {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
}
}
const image = new Image();
image.onload = () => render(image);
image.onerror = () => render();
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
return () => { image.onload = null; image.onerror = null; };
const dynamic = Promise.all([...imageReferences].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
void Promise.all([background, dynamic]).then(([image, loaded]) => {
if (!active) return;
const resourceImages = Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
render(image, resourceImages);
});
return () => { active = false; };
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
+10 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
@@ -230,6 +230,15 @@ export async function restoreProject(options: ClientOptions = {}): Promise<Proje
return response.json() as Promise<ProjectRestoreResponse>;
}
export async function reverseGeocodeLocation(body: ReverseGeocodeRequest, options: ClientOptions = {}): Promise<ReverseGeocodeResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
headers.set("Content-Type", "application/json");
const response = await request(`${options.baseUrl ?? ""}/api/v1/location/reverse-geocode`, { body: JSON.stringify(body), method: "POST", headers });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ReverseGeocodeResponse>;
}
export async function saveLatestExport(body: FormData, options: ClientOptions = {}): Promise<LatestExportSaveResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
+11
View File
@@ -715,6 +715,17 @@ export type RegistrationSendResponse = {
"status": "verification_sent";
};
export type ReverseGeocodeRequest = {
"latitude": number;
"longitude": number;
};
export type ReverseGeocodeResponse = {
"formatted_value": string;
"service_mode": "mock";
"status": "resolved";
};
export type SseEvent = {
"entity_ref": string;
"event_id": number;
+197
View File
@@ -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);
});
}