@7365admin1/layer-common 4.94.0 → 4.95.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -4
- package/components/VehicleQrStickerDialog.vue +83 -16
- package/package.json +1 -1
- package/utils/vehicle-qr-design.ts +268 -0
- package/utils/vehicle-qr-sticker.ts +26 -15
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @iservice365/layer-common
|
|
2
2
|
|
|
3
|
+
## 4.95.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 1456f7a: Vehicle QR stickers: choose a sticker design before printing - QR dot shape, corner shape, colour theme (dark on light only), sticker shape and the words under the plate. Every design is decode-tested; the QR content, size (32 mm), error correction and quiet zone are unchanged. The last choice is remembered in the browser.
|
|
8
|
+
|
|
3
9
|
## 4.94.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -2060,11 +2066,11 @@
|
|
|
2060
2066
|
`utils/console-tier.ts` + `composables/useConsoleTier.ts` mirror the server's
|
|
2061
2067
|
own rule from two endpoints the console already calls, unprojected:
|
|
2062
2068
|
|
|
2063
|
-
|
|
2064
|
-
|
|
2069
|
+
GET /api/members/user/:user/app/admin the Seven365 staff membership
|
|
2070
|
+
GET /api/roles/id/:role that membership's role document
|
|
2065
2071
|
|
|
2066
|
-
|
|
2067
|
-
|
|
2072
|
+
owner = member.type === "admin" && role.type === "admin" && role.default === true
|
|
2073
|
+
staff = member.type === "admin" && role.type === "admin"
|
|
2068
2074
|
|
|
2069
2075
|
`role.default` is the marker because it is the only property of a platform
|
|
2070
2076
|
staff role no API caller can set - `role.controller.ts` validates create and
|
|
@@ -76,7 +76,34 @@
|
|
|
76
76
|
Expired, blocklisted and inactive vehicles start unticked. Each sticker has a cut-off strip under the
|
|
77
77
|
dashed line saying which unit and owner it is for - cut it off before pasting the sticker on the car.
|
|
78
78
|
</p>
|
|
79
|
-
<
|
|
79
|
+
<div class="text-subtitle-2 mt-3 mb-1">Sticker design</div>
|
|
80
|
+
<p class="text-caption text-medium-emphasis mb-2">
|
|
81
|
+
Pick a look for the stickers. Every choice here has been tested to scan with iSecure365 and iMA365, and the
|
|
82
|
+
QR code stays the same size. Your choice is remembered on this computer.
|
|
83
|
+
</p>
|
|
84
|
+
<v-row dense data-test="vehicle-qr-design">
|
|
85
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
86
|
+
<v-select v-model="design.dots" :items="DOT_OPTIONS" label="QR dots" density="compact" hide-details />
|
|
87
|
+
</v-col>
|
|
88
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
89
|
+
<v-select v-model="design.eyes" :items="EYE_OPTIONS" label="QR corners" density="compact" hide-details />
|
|
90
|
+
</v-col>
|
|
91
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
92
|
+
<v-select v-model="design.theme" :items="STICKER_THEMES" item-value="id" label="Colours" density="compact"
|
|
93
|
+
hide-details />
|
|
94
|
+
</v-col>
|
|
95
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
96
|
+
<v-select v-model="design.frame" :items="FRAME_OPTIONS" label="Sticker shape" density="compact" hide-details />
|
|
97
|
+
</v-col>
|
|
98
|
+
<v-col cols="12" sm="6" md="4" lg>
|
|
99
|
+
<v-select v-model="design.label" :items="LABEL_OPTIONS" label="Words under the plate" density="compact"
|
|
100
|
+
hide-details />
|
|
101
|
+
</v-col>
|
|
102
|
+
<v-col cols="12" sm="6" md="4" lg="auto" class="d-flex align-center">
|
|
103
|
+
<v-btn variant="text" size="small" class="text-none" @click="resetDesign">Back to the standard look</v-btn>
|
|
104
|
+
</v-col>
|
|
105
|
+
</v-row>
|
|
106
|
+
<iframe ref="frame" title="Sticker sheet preview" :srcdoc="docHtml" class="sticker-preview mt-3" />
|
|
80
107
|
</template>
|
|
81
108
|
</v-card-text>
|
|
82
109
|
|
|
@@ -114,6 +141,18 @@ import {
|
|
|
114
141
|
type StickerItem,
|
|
115
142
|
type StickerPlateRow,
|
|
116
143
|
} from "../utils/vehicle-qr-sticker";
|
|
144
|
+
import {
|
|
145
|
+
DEFAULT_STICKER_DESIGN,
|
|
146
|
+
DOT_OPTIONS,
|
|
147
|
+
EYE_OPTIONS,
|
|
148
|
+
FRAME_OPTIONS,
|
|
149
|
+
LABEL_OPTIONS,
|
|
150
|
+
QR_ECC,
|
|
151
|
+
STICKER_THEMES,
|
|
152
|
+
normalizeDesign,
|
|
153
|
+
stickerFaceSvg,
|
|
154
|
+
type StickerDesign,
|
|
155
|
+
} from "../utils/vehicle-qr-design";
|
|
117
156
|
|
|
118
157
|
const props = defineProps({
|
|
119
158
|
modelValue: { type: Boolean, default: false },
|
|
@@ -143,6 +182,29 @@ const error = ref("");
|
|
|
143
182
|
const docHtml = ref("");
|
|
144
183
|
const frame = ref<HTMLIFrameElement | null>(null);
|
|
145
184
|
const fromReissue = computed(() => props.vehicleIds.length > 0);
|
|
185
|
+
const siteName = ref("");
|
|
186
|
+
|
|
187
|
+
// The last design chosen in this browser. Storage can be blocked (private
|
|
188
|
+
// window): then it is simply the standard look each time.
|
|
189
|
+
const DESIGN_KEY = "vehicleQrStickerDesign";
|
|
190
|
+
function loadDesign(): StickerDesign {
|
|
191
|
+
try {
|
|
192
|
+
return normalizeDesign(JSON.parse(localStorage.getItem(DESIGN_KEY) || "null"));
|
|
193
|
+
} catch {
|
|
194
|
+
return { ...DEFAULT_STICKER_DESIGN };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const design = reactive<StickerDesign>(loadDesign());
|
|
198
|
+
watch(design, (d) => {
|
|
199
|
+
try {
|
|
200
|
+
localStorage.setItem(DESIGN_KEY, JSON.stringify(d));
|
|
201
|
+
} catch {
|
|
202
|
+
/* not saved - nothing else to do */
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
function resetDesign() {
|
|
206
|
+
Object.assign(design, DEFAULT_STICKER_DESIGN);
|
|
207
|
+
}
|
|
146
208
|
|
|
147
209
|
const allOnPageTicked = computed(() => plates.value.length > 0 && plates.value.every((p) => selected.value.includes(p.id)));
|
|
148
210
|
const someOnPageTicked = computed(() => plates.value.some((p) => selected.value.includes(p.id)));
|
|
@@ -203,6 +265,7 @@ async function goReview() {
|
|
|
203
265
|
const { ids, dropped: over } = idsForRequest(selected.value);
|
|
204
266
|
const res = await getVehicleQrStickers(props.site, ids);
|
|
205
267
|
items.value = (res?.items ?? []) as StickerItem[];
|
|
268
|
+
siteName.value = String((res as any)?.site?.name ?? "");
|
|
206
269
|
missing.value = res?.missing ?? [];
|
|
207
270
|
dropped.value = over;
|
|
208
271
|
ticked.value = defaultStickerIds(items.value);
|
|
@@ -214,21 +277,25 @@ async function goReview() {
|
|
|
214
277
|
}
|
|
215
278
|
}
|
|
216
279
|
|
|
217
|
-
// The same document is the preview and the print job.
|
|
218
|
-
// 4-module quiet zone
|
|
219
|
-
|
|
220
|
-
watch(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
280
|
+
// The same document is the preview and the print job. The QR content, error
|
|
281
|
+
// correction (Q), 4-module quiet zone and 32 mm size are the same on every
|
|
282
|
+
// design; only the look of the dots, corners, colours and frame changes.
|
|
283
|
+
watch(
|
|
284
|
+
[items, ticked, design, siteName],
|
|
285
|
+
() => {
|
|
286
|
+
const chosen = items.value.filter((i) => ticked.value.includes(i.vehicleId));
|
|
287
|
+
if (!chosen.length) {
|
|
288
|
+
docHtml.value = "";
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const look = normalizeDesign(design);
|
|
292
|
+
const faces = chosen.map((i) =>
|
|
293
|
+
stickerFaceSvg(QRCode.create(i.qr, { errorCorrectionLevel: QR_ECC as QRCode.QRCodeErrorCorrectionLevel }).modules, look),
|
|
294
|
+
);
|
|
295
|
+
docHtml.value = buildStickerDocument(chosen, faces, { design: look, siteName: siteName.value });
|
|
296
|
+
},
|
|
297
|
+
{ deep: true },
|
|
298
|
+
);
|
|
232
299
|
|
|
233
300
|
function print() {
|
|
234
301
|
const win = frame.value?.contentWindow;
|
package/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "@7365admin1/layer-common",
|
|
3
3
|
"license": "MIT",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "4.
|
|
5
|
+
"version": "4.95.0",
|
|
6
6
|
"author": "7365admin1",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
8
8
|
"//files": "What a consumer extending this layer actually loads. Without this npm ships the whole working tree - the changesets, the CI workflows, the render harness in tools/ and any scratch directory that happened to exist at publish time. Nuxt resolves a layer by directory, so every runtime directory below has to stay listed; adding a new top-level runtime directory means adding it here too.",
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vehicle QR sticker designs - the sticker face (frame + QR) as plain shapes.
|
|
3
|
+
*
|
|
4
|
+
* The QR library only gives us the dark/light module grid; we draw it here so
|
|
5
|
+
* the look can change while the code itself does not: same payload, same error
|
|
6
|
+
* correction (Q), a 4-module quiet zone and a 32 mm QR on every design. The
|
|
7
|
+
* shapes are kept as data (`stickerFacePaints`) so the test can draw them to
|
|
8
|
+
* pixels and decode them - every design must still scan. Colours are dark ink
|
|
9
|
+
* on light paper only; the test checks each theme's contrast.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type QrMatrix = { size: number; get(row: number, col: number): number | boolean };
|
|
13
|
+
|
|
14
|
+
export type DotShape = "square" | "rounded" | "dots";
|
|
15
|
+
export type EyeShape = "square" | "rounded" | "circle";
|
|
16
|
+
export type FrameShape = "rounded" | "square" | "soft" | "arch" | "ticket";
|
|
17
|
+
export type LabelChoice = "app" | "verify" | "site" | "none";
|
|
18
|
+
|
|
19
|
+
export type StickerTheme = { id: string; title: string; ink: string; eye: string; paper: string };
|
|
20
|
+
|
|
21
|
+
export type StickerDesign = { dots: DotShape; eyes: EyeShape; theme: string; frame: FrameShape; label: LabelChoice };
|
|
22
|
+
|
|
23
|
+
export const DOT_OPTIONS: { value: DotShape; title: string }[] = [
|
|
24
|
+
{ value: "square", title: "Squares (classic)" },
|
|
25
|
+
{ value: "rounded", title: "Rounded squares" },
|
|
26
|
+
{ value: "dots", title: "Round dots" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const EYE_OPTIONS: { value: EyeShape; title: string }[] = [
|
|
30
|
+
{ value: "square", title: "Square" },
|
|
31
|
+
{ value: "rounded", title: "Rounded" },
|
|
32
|
+
{ value: "circle", title: "Circle" },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const FRAME_OPTIONS: { value: FrameShape; title: string }[] = [
|
|
36
|
+
{ value: "rounded", title: "Rounded corners" },
|
|
37
|
+
{ value: "square", title: "Square corners" },
|
|
38
|
+
{ value: "soft", title: "Extra round" },
|
|
39
|
+
{ value: "arch", title: "Badge (round top)" },
|
|
40
|
+
{ value: "ticket", title: "Ticket (cut corners)" },
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
export const LABEL_OPTIONS: { value: LabelChoice; title: string }[] = [
|
|
44
|
+
{ value: "app", title: "Scan with iSecure365 / iMA365" },
|
|
45
|
+
{ value: "verify", title: "Scan to verify vehicle" },
|
|
46
|
+
{ value: "site", title: "Site name" },
|
|
47
|
+
{ value: "none", title: "No extra words" },
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Dark ink on light paper only - never inverted, never pale. Checked by test.
|
|
51
|
+
export const STICKER_THEMES: StickerTheme[] = [
|
|
52
|
+
{ id: "black", title: "Black on white", ink: "#000000", eye: "#000000", paper: "#ffffff" },
|
|
53
|
+
{ id: "navy", title: "Navy blue", ink: "#0b2545", eye: "#0b2545", paper: "#ffffff" },
|
|
54
|
+
{ id: "navy-red", title: "Navy with red corners", ink: "#0b2545", eye: "#8b1a1a", paper: "#ffffff" },
|
|
55
|
+
{ id: "green", title: "Dark green on mint", ink: "#14462b", eye: "#14462b", paper: "#f2faf4" },
|
|
56
|
+
{ id: "maroon", title: "Maroon on cream", ink: "#5c0f1d", eye: "#5c0f1d", paper: "#fff8ee" },
|
|
57
|
+
{ id: "purple", title: "Deep purple", ink: "#33105c", eye: "#33105c", paper: "#ffffff" },
|
|
58
|
+
{ id: "charcoal", title: "Charcoal on cream", ink: "#222222", eye: "#222222", paper: "#fffbea" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
export const DEFAULT_STICKER_DESIGN: StickerDesign = {
|
|
62
|
+
dots: "square",
|
|
63
|
+
eyes: "square",
|
|
64
|
+
theme: "black",
|
|
65
|
+
frame: "rounded",
|
|
66
|
+
label: "app",
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** A saved or typed-in design with anything unknown replaced by the default. */
|
|
70
|
+
export function normalizeDesign(raw: unknown): StickerDesign {
|
|
71
|
+
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
|
72
|
+
const pick = <T extends string>(v: unknown, list: { value: T }[], d: T): T =>
|
|
73
|
+
list.some((o) => o.value === v) ? (v as T) : d;
|
|
74
|
+
const d = DEFAULT_STICKER_DESIGN;
|
|
75
|
+
return {
|
|
76
|
+
dots: pick(r.dots, DOT_OPTIONS, d.dots),
|
|
77
|
+
eyes: pick(r.eyes, EYE_OPTIONS, d.eyes),
|
|
78
|
+
theme: STICKER_THEMES.some((t) => t.id === r.theme) ? String(r.theme) : d.theme,
|
|
79
|
+
frame: pick(r.frame, FRAME_OPTIONS, d.frame),
|
|
80
|
+
label: pick(r.label, LABEL_OPTIONS, d.label),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function stickerTheme(id: string): StickerTheme {
|
|
85
|
+
return STICKER_THEMES.find((t) => t.id === id) ?? STICKER_THEMES[0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** WCAG relative luminance of "#rrggbb". */
|
|
89
|
+
export function stickerLuminance(hex: string): number {
|
|
90
|
+
const n = parseInt(hex.slice(1), 16);
|
|
91
|
+
const lin = (c: number) => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
92
|
+
return 0.2126 * lin(((n >> 16) & 255) / 255) + 0.7152 * lin(((n >> 8) & 255) / 255) + 0.0722 * lin((n & 255) / 255);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function stickerContrast(a: string, b: string): number {
|
|
96
|
+
const [hi, lo] = [stickerLuminance(a), stickerLuminance(b)].sort((x, y) => y - x);
|
|
97
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- geometry (all in mm; the card is 47 x 58) ----------------------------
|
|
101
|
+
|
|
102
|
+
export const CARD_W = 47;
|
|
103
|
+
export const CARD_H = 58;
|
|
104
|
+
export const QR_MM = 32; // the whole QR including its quiet zone
|
|
105
|
+
export const QUIET_MODULES = 4;
|
|
106
|
+
/** Error correction on every design (no centre logo, so Q stays enough). */
|
|
107
|
+
export const QR_ECC = "Q";
|
|
108
|
+
const STROKE = 0.35;
|
|
109
|
+
|
|
110
|
+
/** A rectangle with per-corner radii [tl, tr, br, bl], or concave cut corners. */
|
|
111
|
+
export type Box = { t: "box"; x: number; y: number; w: number; h: number; r?: number[]; notch?: number };
|
|
112
|
+
export type Circle = { t: "circle"; cx: number; cy: number; r: number };
|
|
113
|
+
export type Shape = Box | Circle;
|
|
114
|
+
/** A filled shape; `hole` is cut out of it (rings, the frame line). */
|
|
115
|
+
export type Paint = { shape: Shape; hole?: Shape; fill: string };
|
|
116
|
+
|
|
117
|
+
/** Top of the QR on the card: lower on the badge so the round top keeps clear of the code. */
|
|
118
|
+
export function qrTop(frame: FrameShape): number {
|
|
119
|
+
return frame === "arch" ? 7 : 2.5;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cardBox(frame: FrameShape, inset: number): Box {
|
|
123
|
+
const x = inset;
|
|
124
|
+
const w = CARD_W - inset * 2;
|
|
125
|
+
const h = CARD_H - inset * 2;
|
|
126
|
+
const r = (v: number) => Math.max(0, v - inset);
|
|
127
|
+
switch (frame) {
|
|
128
|
+
case "square":
|
|
129
|
+
return { t: "box", x, y: x, w, h, r: [r(0.8), r(0.8), r(0.8), r(0.8)] };
|
|
130
|
+
case "soft":
|
|
131
|
+
return { t: "box", x, y: x, w, h, r: [r(8), r(8), r(8), r(8)] };
|
|
132
|
+
case "arch":
|
|
133
|
+
return { t: "box", x, y: x, w, h, r: [w / 2, w / 2, r(3), r(3)] };
|
|
134
|
+
case "ticket":
|
|
135
|
+
return { t: "box", x, y: x, w, h, notch: 4 + inset };
|
|
136
|
+
default:
|
|
137
|
+
return { t: "box", x, y: x, w, h, r: [r(3), r(3), r(3), r(3)] };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isFinder(n: number, row: number, col: number): boolean {
|
|
142
|
+
return (row < 7 && col < 7) || (row < 7 && col >= n - 7) || (row >= n - 7 && col < 7);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Every shape on the sticker face, painted in order. */
|
|
146
|
+
export function stickerFacePaints(matrix: QrMatrix, design: StickerDesign): Paint[] {
|
|
147
|
+
const theme = stickerTheme(design.theme);
|
|
148
|
+
const n = matrix.size;
|
|
149
|
+
const m = QR_MM / (n + QUIET_MODULES * 2);
|
|
150
|
+
const ox = (CARD_W - QR_MM) / 2 + QUIET_MODULES * m;
|
|
151
|
+
const oy = qrTop(design.frame) + QUIET_MODULES * m;
|
|
152
|
+
const paints: Paint[] = [
|
|
153
|
+
{ shape: cardBox(design.frame, 0), fill: theme.paper },
|
|
154
|
+
{ shape: cardBox(design.frame, 0), hole: cardBox(design.frame, STROKE), fill: theme.ink },
|
|
155
|
+
];
|
|
156
|
+
const box = (x: number, y: number, w: number, h: number, rad = 0): Box => ({ t: "box", x, y, w, h, r: [rad, rad, rad, rad] });
|
|
157
|
+
|
|
158
|
+
for (let row = 0; row < n; row++) {
|
|
159
|
+
for (let col = 0; col < n; col++) {
|
|
160
|
+
if (!matrix.get(row, col) || isFinder(n, row, col)) continue;
|
|
161
|
+
const x = ox + col * m;
|
|
162
|
+
const y = oy + row * m;
|
|
163
|
+
if (design.dots === "dots") {
|
|
164
|
+
paints.push({ shape: { t: "circle", cx: x + m / 2, cy: y + m / 2, r: m * 0.45 }, fill: theme.ink });
|
|
165
|
+
} else if (design.dots === "rounded") {
|
|
166
|
+
paints.push({ shape: box(x, y, m, m, m * 0.3), fill: theme.ink });
|
|
167
|
+
} else {
|
|
168
|
+
// ponytail: one rect per horizontal run, so adjacent squares never show a hairline seam
|
|
169
|
+
let end = col;
|
|
170
|
+
while (end + 1 < n && matrix.get(row, end + 1) && !isFinder(n, row, end + 1)) end++;
|
|
171
|
+
paints.push({ shape: box(x, y, (end - col + 1) * m, m), fill: theme.ink });
|
|
172
|
+
col = end;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const [r0, c0] of [[0, 0], [0, n - 7], [n - 7, 0]]) {
|
|
178
|
+
const x = ox + c0 * m;
|
|
179
|
+
const y = oy + r0 * m;
|
|
180
|
+
if (design.eyes === "circle") {
|
|
181
|
+
const cx = x + 3.5 * m;
|
|
182
|
+
const cy = y + 3.5 * m;
|
|
183
|
+
paints.push({ shape: { t: "circle", cx, cy, r: 3.5 * m }, hole: { t: "circle", cx, cy, r: 2.5 * m }, fill: theme.eye });
|
|
184
|
+
paints.push({ shape: { t: "circle", cx, cy, r: 1.5 * m }, fill: theme.eye });
|
|
185
|
+
} else {
|
|
186
|
+
const round = design.eyes === "rounded";
|
|
187
|
+
paints.push({
|
|
188
|
+
shape: box(x, y, 7 * m, 7 * m, round ? 2 * m : 0),
|
|
189
|
+
hole: box(x + m, y + m, 5 * m, 5 * m, round ? 1.2 * m : 0),
|
|
190
|
+
fill: theme.eye,
|
|
191
|
+
});
|
|
192
|
+
paints.push({ shape: box(x + 2 * m, y + 2 * m, 3 * m, 3 * m, round ? 0.9 * m : 0), fill: theme.eye });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return paints;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ---- drawing ---------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
const f = (v: number) => String(Math.round(v * 1000) / 1000);
|
|
201
|
+
|
|
202
|
+
function boxPath(b: Box): string {
|
|
203
|
+
const { x, y, w, h } = b;
|
|
204
|
+
if (b.notch) {
|
|
205
|
+
const k = b.notch;
|
|
206
|
+
return (
|
|
207
|
+
`M${f(x + k)} ${f(y)}H${f(x + w - k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + w)} ${f(y + k)}` +
|
|
208
|
+
`V${f(y + h - k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + w - k)} ${f(y + h)}` +
|
|
209
|
+
`H${f(x + k)}A${f(k)} ${f(k)} 0 0 0 ${f(x)} ${f(y + h - k)}` +
|
|
210
|
+
`V${f(y + k)}A${f(k)} ${f(k)} 0 0 0 ${f(x + k)} ${f(y)}Z`
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
const [tl, tr, br, bl] = b.r ?? [0, 0, 0, 0];
|
|
214
|
+
const arc = (r: number, ex: number, ey: number) => (r ? `A${f(r)} ${f(r)} 0 0 1 ${f(ex)} ${f(ey)}` : "");
|
|
215
|
+
return (
|
|
216
|
+
`M${f(x + tl)} ${f(y)}H${f(x + w - tr)}${arc(tr, x + w, y + tr)}` +
|
|
217
|
+
`V${f(y + h - br)}${arc(br, x + w - br, y + h)}` +
|
|
218
|
+
`H${f(x + bl)}${arc(bl, x, y + h - bl)}` +
|
|
219
|
+
`V${f(y + tl)}${arc(tl, x + tl, y)}Z`
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function shapePath(s: Shape): string {
|
|
224
|
+
if (s.t === "box") return boxPath(s);
|
|
225
|
+
const { cx, cy, r } = s;
|
|
226
|
+
return `M${f(cx - r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx + r)} ${f(cy)}A${f(r)} ${f(r)} 0 1 0 ${f(cx - r)} ${f(cy)}Z`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The face as one SVG, 47 x 58 mm. Runs of same-colour shapes share one path. */
|
|
230
|
+
export function paintsToSvg(paints: Paint[]): string {
|
|
231
|
+
const out: string[] = [];
|
|
232
|
+
let fill = "";
|
|
233
|
+
let d = "";
|
|
234
|
+
const flush = () => d && out.push(`<path fill="${fill}" d="${d}"/>`);
|
|
235
|
+
for (const p of paints) {
|
|
236
|
+
if (p.hole) {
|
|
237
|
+
// A ring is its own evenodd path, so its hole never cuts a neighbour.
|
|
238
|
+
flush();
|
|
239
|
+
d = "";
|
|
240
|
+
out.push(`<path fill="${p.fill}" fill-rule="evenodd" d="${shapePath(p.shape)}${shapePath(p.hole)}"/>`);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (p.fill !== fill) {
|
|
244
|
+
flush();
|
|
245
|
+
fill = p.fill;
|
|
246
|
+
d = "";
|
|
247
|
+
}
|
|
248
|
+
d += shapePath(p.shape);
|
|
249
|
+
}
|
|
250
|
+
flush();
|
|
251
|
+
return (
|
|
252
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${CARD_W} ${CARD_H}" width="${CARD_W}mm" height="${CARD_H}mm">` +
|
|
253
|
+
out.join("") +
|
|
254
|
+
`</svg>`
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function stickerFaceSvg(matrix: QrMatrix, design: StickerDesign): string {
|
|
259
|
+
return paintsToSvg(stickerFacePaints(matrix, design));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The words under the plate for this design ("" = none). */
|
|
263
|
+
export function stickerLabel(design: StickerDesign, siteName = ""): string {
|
|
264
|
+
if (design.label === "none") return "";
|
|
265
|
+
if (design.label === "verify") return "Scan to verify vehicle";
|
|
266
|
+
if (design.label === "site") return String(siteName ?? "").trim();
|
|
267
|
+
return "Scan with iSecure365 / iMA365";
|
|
268
|
+
}
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* pasted on the car.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import { DEFAULT_STICKER_DESIGN, QR_MM, qrTop, stickerLabel, stickerTheme, type StickerDesign } from "./vehicle-qr-design.ts";
|
|
12
|
+
|
|
11
13
|
/** The server's most vehicles per request (POST /api/vehicles/qr/stickers). */
|
|
12
14
|
export const STICKER_MAX_PER_REQUEST = 500;
|
|
13
15
|
|
|
@@ -146,13 +148,13 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
|
|
|
146
148
|
.page { display: grid; grid-template-columns: repeat(${STICKER_COLUMNS}, 47mm); gap: 2mm; justify-content: center; align-content: start; width: 198mm; height: 285mm; margin: 0 auto; break-after: page; }
|
|
147
149
|
.page:last-child { break-after: auto; }
|
|
148
150
|
.cell { width: 47mm; break-inside: avoid; }
|
|
149
|
-
.card { width: 47mm; height: 58mm;
|
|
150
|
-
.
|
|
151
|
-
.
|
|
151
|
+
.card { position: relative; width: 47mm; height: 58mm; }
|
|
152
|
+
.face, .face svg { position: absolute; inset: 0; width: 47mm; height: 58mm; display: block; }
|
|
153
|
+
.words { position: absolute; left: 3mm; right: 3mm; bottom: 2mm; display: flex; flex-direction: column; align-items: center; justify-content: space-between; text-align: center; }
|
|
152
154
|
.plate { font-size: 13pt; font-weight: 800; letter-spacing: 0.3mm; line-height: 1.1; }
|
|
153
|
-
.hint { font-size: 5.8pt;
|
|
154
|
-
.brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt;
|
|
155
|
-
.mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm;
|
|
155
|
+
.hint { font-size: 5.8pt; line-height: 1.2; opacity: 0.85; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
|
156
|
+
.brand { display: flex; align-items: center; justify-content: center; gap: 1mm; font-size: 6pt; border-top: 0.2mm solid #ccc; width: 100%; padding-top: 1mm; }
|
|
157
|
+
.mark { width: 3.2mm; height: 3.2mm; border-radius: 0.8mm; color: #fff; font-weight: 800; font-size: 5pt; line-height: 3.2mm; text-align: center; }
|
|
156
158
|
.cut { margin-top: 1.2mm; border-top: 0.3mm dashed #666; padding-top: 1mm; font-size: 6.5pt; line-height: 1.25; color: #111; min-height: 8mm; }
|
|
157
159
|
.cut .owner { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
158
160
|
.cut .note { color: #666; font-size: 5.5pt; }
|
|
@@ -160,20 +162,29 @@ html, body { margin: 0; padding: 0; background: #fff; color: #111; font-family:
|
|
|
160
162
|
`;
|
|
161
163
|
|
|
162
164
|
/**
|
|
163
|
-
* One full HTML document: A4 sheets of stickers. `
|
|
164
|
-
* `items` (
|
|
165
|
-
* owner name is typed by a person.
|
|
165
|
+
* One full HTML document: A4 sheets of stickers. `faceSvgs` is parallel to
|
|
166
|
+
* `items` (each sticker's frame + QR, already drawn for the chosen design).
|
|
167
|
+
* Every value is HTML-escaped - an owner or site name is typed by a person.
|
|
166
168
|
*/
|
|
167
|
-
export function buildStickerDocument(
|
|
169
|
+
export function buildStickerDocument(
|
|
170
|
+
items: StickerItem[],
|
|
171
|
+
faceSvgs: string[],
|
|
172
|
+
opts: { design?: StickerDesign; siteName?: string } = {},
|
|
173
|
+
): string {
|
|
174
|
+
const design = opts.design ?? DEFAULT_STICKER_DESIGN;
|
|
175
|
+
const { ink } = stickerTheme(design.theme);
|
|
176
|
+
const label = stickerLabel(design, opts.siteName);
|
|
177
|
+
const wordsTop = qrTop(design.frame) + QR_MM + 0.8;
|
|
168
178
|
const cells = items.map((item, i) => {
|
|
169
179
|
const { place, owner } = installerLine(item);
|
|
170
180
|
return (
|
|
171
|
-
`<div class="cell"><div class="card">` +
|
|
172
|
-
`<div class="
|
|
181
|
+
`<div class="cell"><div class="card" style="color:${ink}">` +
|
|
182
|
+
`<div class="face">${faceSvgs[i] ?? ""}</div>` +
|
|
183
|
+
`<div class="words" style="top:${wordsTop}mm">` +
|
|
173
184
|
`<div class="plate">${escapeHtml(item.plateNumber)}</div>` +
|
|
174
|
-
`<div class="hint"
|
|
175
|
-
`<div class="brand"><span class="mark">7</span>Powered by Seven365</div>` +
|
|
176
|
-
`</div>` +
|
|
185
|
+
(label ? `<div class="hint">${escapeHtml(label)}</div>` : "") +
|
|
186
|
+
`<div class="brand"><span class="mark" style="background:${ink}">7</span>Powered by Seven365</div>` +
|
|
187
|
+
`</div></div>` +
|
|
177
188
|
`<div class="cut"><div><b>${escapeHtml(place || "-")}</b></div>` +
|
|
178
189
|
`<div class="owner">Owner: ${escapeHtml(owner || "-")}</div>` +
|
|
179
190
|
`<div class="note">Installer: cut here, then paste on this car</div></div></div>`
|