Embedding the components
Four custom elements show a packing result in your pages: a summary, the algorithm ranking, the list of containers and a 3D
view of one container. They only display a result. Your backend calls POST /api/pack (Calling the API) and your
frontend hands them the response.
| Element | Shows |
|---|---|
<cartonization-summary> |
Summary tiles (containers, fill rate, packed, unpacked, objective, cost, weight utilization, time) and the unpacked units with their reasons |
<cartonization-ranking> |
The algorithm ranking, winner highlighted. Clicking a row shows that algorithm's solution in the whole group (needs includeAllSolutions: true) |
<cartonization-containers> |
The containers of the shown solution. Clicking a row selects that container in the group |
<cartonization-3d-view> |
One container in 3D (orbit controls), with a caption and a color legend |
They are standard custom elements in one ES module, so they work in plain HTML and in every framework. Components for
multi-level and capacity results are not available yet; today the components display /api/pack results.
Loading the script#
One ES module defines all four components (about 584 KB minified, 148 KB gzipped, mostly three.js). Your install serves it at
two URLs, both with Access-Control-Allow-Origin: *:
| URL | Caching | Use |
|---|---|---|
/widget/1.0.0/cartonization-components.js |
public, max-age=31536000, immutable |
Production: pin the version and add SRI |
/widget/cartonization-components.js |
no-cache |
Always the installed version; fine for development |
For this install (version 1.0.0), with its Subresource Integrity hash:
<script type="module" src="https://cartonization.internal.example/widget/1.0.0/cartonization-components.js"
integrity="sha384-IY2t1rFsgll1gbkYp9AID/GajBy2fLKHFBMc8EjjMqft4DYUgwKM/OQWge93UbcZ" crossorigin="anonymous"></script>Replace the host with your install's. The hash belongs to this version: after an upgrade, take the new one from these pages on the upgraded install, or compute it from the versioned URL:
curl -s https://cartonization.internal.example/widget/1.0.0/cartonization-components.js | openssl dgst -sha384 -binary | openssl base64 -A- Types. The TypeScript declarations are served next to the script at
/widget/1.0.0/cartonization-components.d.ts. Copy the file into your frontend's sources to type the elements, their events andgetGroup()(the framework samples do). - Content-Security-Policy. The components work under a strict CSP with no
'unsafe-inline'or'unsafe-eval'. Allow the install inscript-src, for examplescript-src 'self' https://cartonization.internal.example; style-src 'self'. The html sample is checked underscript-src 'self' <install>; style-src 'self'; connect-src <your backend>. - One copy per page. If your code also imports
getGroupfrom the module, import it from the same URL as the<script>tag, so the browser loads the module once and the page has one set of groups.
The trigger flow#
The main pattern: the user clicks your button, your frontend calls your backend, and the response goes to a group of
components. Before the first click the components show their empty state ("No result to show."). This is the plain HTML
sample, examples/html. The form, the button and the status line are your own markup:
<!-- Your own order form and button. The components only display the result your backend returns. -->
<form id="order-form" class="order">
<label>Order <input id="order-id" name="orderId" value="SO-1001" required></label>
<table>
<thead>
<tr><th>SKU</th><th>Quantity</th></tr>
</thead>
<tbody>
<tr><td><input id="line-sku-0" value="BOOK" aria-label="SKU, line 1"></td><td><input id="line-qty-0" type="number" min="1" max="1000" value="6" required aria-label="Quantity, line 1"></td></tr>
<tr><td><input id="line-sku-1" value="MUG" aria-label="SKU, line 2"></td><td><input id="line-qty-1" type="number" min="1" max="1000" value="4" required aria-label="Quantity, line 2"></td></tr>
<tr><td><input id="line-sku-2" value="LAMP" aria-label="SKU, line 3"></td><td><input id="line-qty-2" type="number" min="1" max="1000" value="1" required aria-label="Quantity, line 3"></td></tr>
<tr><td><input id="line-sku-3" value="CABLE" aria-label="SKU, line 4"></td><td><input id="line-qty-3" type="number" min="1" max="1000" value="8" required aria-label="Quantity, line 4"></td></tr>
<tr><td><input id="line-sku-4" value="LIGHTER-FLUID" aria-label="SKU, line 5"></td><td><input id="line-qty-4" type="number" min="1" max="1000" value="4" required aria-label="Quantity, line 5"></td></tr>
</tbody>
</table>
<button id="cartonize" type="submit">Cartonize</button>
<p id="cartonize-status" class="status" role="status" data-state="idle"></p>
</form>
<!-- One group: the 3D view and the container list stay in sync, and so do the components in the info panel. -->
<cartonization-3d-view id="station-3d" group="station"></cartonization-3d-view>
<cartonization-containers id="station-containers" group="station"></cartonization-containers>
<section id="info-panel" hidden>
<cartonization-summary id="info-summary" group="station"></cartonization-summary>
<cartonization-ranking id="info-ranking" group="station"></cartonization-ranking>
</section>The script loads the components from the install, and your own code after it:
<!-- The components, from your cartonization install. In production pin the versioned URL (/widget/<version>/...) and add
integrity="sha384-..." crossorigin="anonymous" (see the docs at /docs/embedding.html on your install). -->
<script type="module" src="http://127.0.0.1:10081/widget/cartonization-components.js"></script>
<script type="module" src="app.js"></script>app.js handles the click. It shows a loading state while your backend works, sets the response on the group, and clears the
group on an error so that no component keeps showing the previous order's packing:
// Same URL as the <script> in index.html, so the browser loads the module once and the page has one set of groups.
import { getGroup } from 'http://127.0.0.1:10081/widget/cartonization-components.js';
/** Your backend (examples/backend-node or backend-java). It builds the request, calls POST /api/pack and returns the response. */
const BACKEND_URL = 'http://127.0.0.1:3001';
const station = getGroup('station');
const form = document.getElementById('order-form');
const button = document.getElementById('cartonize');
const status = document.getElementById('cartonize-status');
const selected = document.getElementById('selected-container');
const toggle = document.getElementById('info-toggle');
const panel = document.getElementById('info-panel');
function showStatus(state, text) {
status.dataset.state = state;
status.textContent = text;
}
/** The order as your backend expects it: { orderId, lines: [{ sku, quantity }] }. Lines with no SKU are skipped. */
function readOrder() {
const lines = [...form.querySelectorAll('tbody tr')]
.map(row => row.querySelectorAll('input'))
.map(([sku, quantity]) => ({ sku: sku.value.trim(), quantity: quantity.valueAsNumber }))
.filter(line => line.sku !== '');
return { orderId: document.getElementById('order-id').value.trim(), lines };
}
// The user clicks your button; your backend packs the order; the page hands the response to the components.
form.addEventListener('submit', async event => {
event.preventDefault();
button.disabled = true;
showStatus('loading', 'Cartonizing...');
try {
const res = await fetch(`${BACKEND_URL}/api/cartonize`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(readOrder()),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(body.error ?? `HTTP ${res.status}`);
}
// Setting the result on the group updates every component in it and selects the best solution and container #1.
station.setResult(body);
const first = body.best.containers[0];
selected.textContent = first ? `Pack container #1: ${first.containerId}` : 'Nothing to pack.';
showStatus('done', `Packed ${body.best.metrics.packedUnits} units into ${body.best.metrics.containerCount} container(s).`);
}
catch (err) {
// Clear the group, so the components don't keep showing the previous order's packing.
station.setResult(null);
selected.textContent = 'No packing.';
showStatus('error', `Could not cartonize: ${err.message}`);
}
finally {
button.disabled = false;
}
});- A new result resets the selection. Each new response selects the best solution and container #1 in the whole group, so after the user changes a quantity and clicks again, every component shows the new packing from the start.
- Errors are yours to show. The components don't know about your request. Show your backend's message on the page (the
samples use a status line) and set the result to
null. - Disable the button while the request runs, so a double click doesn't send two requests.
result#
Set the result property (not an attribute) to a POST /api/pack response, or to a bare Solution (for example a stored
best). Set it to null to clear. A property set before the script has loaded is picked up when the element is defined.
A new result selects the best solution and container #1. Setting the same object again does nothing, so a framework binding
that re-sends an unchanged result on re-render keeps the user's selection. Set it on one element of the group (or with
getGroup(id).setResult(...)); the other components of the group show it too.
Groups and getGroup()#
Components with the same group attribute share one state: the result, the selected algorithm and the selected container.
They can be anywhere in the page and attached at any time; a component attached later (a tab, an info panel) shows the
group's current state. A component without group is standalone and keeps its own state.
The module also exports getGroup(id), for code that drives a group without going through an element:
// Import from the same URL as the <script> tag, so the page has one copy of the module (and of its groups).
import { getGroup } from 'https://cartonization.internal.example/widget/1.0.0/cartonization-components.js';
const group = getGroup('station');
group.setResult(packing); // like setting `result` on any element of the group; null clears it
group.select({ algorithm: 'extreme-points' }); // false if the result has no such solution
group.select({ container: 2 });
const unsubscribe = group.subscribe(state => console.log(state.selectedAlgorithm, state.selectedContainer));group.state has result, selectedAlgorithm, selectedContainer (1-based) and solution (the shown one). subscribe
calls the listener right away with the current state, then on every change, and returns the unsubscribe function. Each element
also has selectedAlgorithm and selectedContainer properties for its group. select() returns false and changes nothing
when the selection is invalid or there is no result.
Pinning a container#
<cartonization-3d-view container="1"> always shows container #1 of the group's shown solution, instead of following the
selected container. Remove the attribute (or set container = null) to follow the selection again.
Events#
The components fire these on user clicks only (select() and property changes don't fire them). They bubble and are composed,
so a listener on document sees them too.
| Event | detail |
|---|---|
cw-solution-select |
{ group, algorithm }: a ranking row was clicked and its solution is now shown |
cw-container-select |
{ group, index, containerId }: a container row was clicked (index is 1-based) |
group is null for a standalone component. From the html sample:
// The components' events bubble out of their shadow roots, so one listener on the document sees them all.
document.addEventListener('cw-container-select', e => {
selected.textContent = `Pack container #${e.detail.index}: ${e.detail.containerId}`;
});Theming#
CSS custom properties, set on the elements or any ancestor (they inherit into the shadow roots):
| Property | Default | |
|---|---|---|
--cw-font-family |
system-ui, ... |
|
--cw-accent |
#2563eb |
Selected rows, the objective's column |
--cw-bg |
#f4f5f7 |
Tile background |
--cw-surface |
#ffffff |
|
--cw-text |
#1d2330 |
|
--cw-muted |
#6b7280 |
Captions, headers, hints |
--cw-border |
#dfe2e8 |
|
--cw-radius |
8px |
|
--cw-3d-height |
420px |
The 3D view's default height. A height set on the element itself wins; the view needs a definite height |
For more, style the parts with ::part():
| Element | Parts |
|---|---|
| all | empty (the "no result" message), hint, caption |
| summary | tiles, tile (with data-tile = containers, fill-rate, packed, unpacked, objective, cost, weight-utilization, time), value, label, unpacked |
| ranking | table, header (the objective's column is also judge), row (also winner, selected), cell |
| containers | table, header, row (also selected), cell (the hazmat container label is also hazmat) |
| 3d-view | caption, viewport, canvas, legend, legend-item, swatch |
cartonization-3d-view, cartonization-containers, cartonization-summary, cartonization-ranking {
--cw-accent: #0f766e;
--cw-3d-height: 520px;
}
cartonization-ranking::part(row winner) {
background: #ccfbf1;
}Common layouts#
- Packing station:
<cartonization-3d-view>and<cartonization-containers>in one group. The packer clicks a container and the 3D view follows. A 3D view alone (no group) works too: set itsresultand, if needed,container. - Info toggle: Summary + Ranking in the same group, behind an "i" button. Hide them (
hidden) or create them only when opened (@if,v-if,{open && ...}): either way they show the group's current state, including a ranking choice made earlier. - Containers only: one
<cartonization-containers>withresultset. The page still loads three.js, because all four components are one bundle. - 3D only: a standalone
<cartonization-3d-view container="1">with its ownresult(the framework samples have one). - Everything: all four in one group, as in the playground.
Framework guides#
Every framework sample has the same order form, Cartonize button, packing station, "i" toggle and a standalone 3D-only
view. The pattern is the same everywhere: load the script once, let the framework pass the custom elements through, set
result as a DOM property (never an attribute), set group and container as attributes, and listen for the events.
HTML#
See the trigger flow: examples/html has no inline scripts or styles, so it runs under a strict CSP.
Angular#
examples/angular: a standalone component with CUSTOM_ELEMENTS_SCHEMA, which lets Angular accept the <cartonization-*>
elements and bind their properties and events. Load the script once, for example in main.ts:
// Load the components from your cartonization install (another origin). In production, pin the versioned URL
// (/widget/<version>/cartonization-components.js), which is cacheable for a year.
const script = document.createElement('script');
script.type = 'module';
script.src = `${CARTONIZATION_URL}/widget/cartonization-components.js`;
document.head.append(script);Bind [result] to a signal, set group as an attribute and handle the events with (cw-...):
<!-- Set the result on one element of the group: every component with group="station" shows it. -->
<cartonization-3d-view id="station-3d" group="station" [result]="packing()"></cartonization-3d-view>
<cartonization-containers id="station-containers" group="station" (cw-container-select)="onContainerSelect($event)"></cartonization-containers>
<!-- Created when opened: components attached later show the group's current state. -->
@if (infoOpen()) {
<section class="info">
<cartonization-summary id="info-summary" group="station"></cartonization-summary>
<cartonization-ranking id="info-ranking" group="station"></cartonization-ranking>
</section>
}
<section class="solo">
<h2>3D only (standalone, container 1)</h2>
<cartonization-3d-view id="solo-3d" container="1" [result]="packing()"></cartonization-3d-view>
</section>The click handler posts the order with HttpClient. On success it sets the signal; on an error it sets null:
/** The user clicked Cartonize: your backend packs the order, and the response goes to the components unchanged. */
protected cartonize(event: Event) {
event.preventDefault();
const order = { orderId: this.orderId().trim(), lines: this.lines().map(l => ({ ...l, sku: l.sku.trim() })).filter(l => l.sku !== '') };
this.status.set({ state: 'loading', text: 'Cartonizing...' });
this.http.post<PackResponse>(`${BACKEND_URL}/api/cartonize`, order).subscribe({
next: packing => {
// A new result selects the best solution and container #1 in the whole group.
this.packing.set(packing);
const first = packing.best.containers[0];
this.selected.set(first ? `Pack container #1: ${first.containerId}` : 'Nothing to pack.');
this.status.set({ state: 'done', text: `Packed ${packing.best.metrics.packedUnits} units into ${packing.best.metrics.containerCount} container(s).` });
},
error: (err: HttpErrorResponse) => {
// Clear the components, so they don't keep showing the previous order's packing.
this.packing.set(null);
this.selected.set('No packing.');
this.status.set({ state: 'error', text: `Could not cartonize: ${err.error?.error ?? err.message}` });
},
});
}
/** Angular types custom-element events as Event; the .d.ts gives the detail's type. */
protected onContainerSelect(event: Event) {
const { detail } = event as CartonizationEventMap['cw-container-select'];
this.selected.set(`Pack container #${detail.index}: ${detail.containerId}`);
}React#
examples/react (React 19 + Vite + TypeScript). Load the script once, as in Angular's main.ts. Declare the elements for JSX
with typed refs:
// Lets TSX use the <cartonization-*> elements with their attributes and typed refs.
import type { DetailedHTMLProps, HTMLAttributes } from 'react';
import type { Cartonization3DView, CartonizationContainers, CartonizationRanking, CartonizationSummary } from './cartonization-components';
type ElementProps<E extends HTMLElement> = DetailedHTMLProps<HTMLAttributes<E>, E> & { group?: string };
declare module 'react' {
namespace JSX {
interface IntrinsicElements {
'cartonization-summary': ElementProps<CartonizationSummary>;
'cartonization-ranking': ElementProps<CartonizationRanking>;
'cartonization-containers': ElementProps<CartonizationContainers>;
'cartonization-3d-view': ElementProps<Cartonization3DView> & { container?: string };
}
}
}Set
resultthrough a ref. React 19 sets a prop on a custom element as a property only when the element already has that property at render time. The script loads asynchronously from your install, so the first render usually happens before the elements are defined, and React then writesresult="[object Object]"as an attribute. A ref sets the property whenever the element is defined. Add the events withaddEventListenerfor the same reason.
// `result` is an object, so set it as a property through a ref. React 19 assigns a prop as a property only when the element
// already has it at render time; the components load asynchronously from another origin, so on the first render they aren't
// defined yet and React would set a string attribute instead. A ref works either way. Setting it on one element of the
// group updates the whole group.
useEffect(() => {
stationRef.current!.result = packing;
soloRef.current!.result = packing;
}, [packing]);
// Custom events: add them with addEventListener. The .d.ts types the event's detail.
useEffect(() => {
const containers = containersRef.current!;
const onSelect = (e: CustomEvent<ContainerSelectDetail>) => setSelected(`Pack container #${e.detail.index}: ${e.detail.containerId}`);
containers.addEventListener('cw-container-select', onSelect);
return () => containers.removeEventListener('cw-container-select', onSelect);
}, []);<cartonization-3d-view ref={stationRef} id="station-3d" group="station" />
<cartonization-containers ref={containersRef} id="station-containers" group="station" />
{/* Mounted when opened: components attached later show the group's current state. */}
{infoOpen && (
<section className="info">
<cartonization-summary id="info-summary" group="station" />
<cartonization-ranking id="info-ranking" group="station" />
</section>
)}
<section className="solo">
<h2>3D only (standalone, container 1)</h2>
<cartonization-3d-view ref={soloRef} id="solo-3d" container="1" />
</section>The click handler calls your backend and sets the state; the effect above passes it to the components:
/** Your backend packs the order; a non-2xx response carries { error } with a message for the user. */
async function cartonize(orderId: string, lines: OrderLine[]): Promise<PackResponse> {
const res = await fetch(`${BACKEND_URL}/api/cartonize`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ orderId, lines }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`);
}
return body as PackResponse;
}Vue#
examples/vue (Vue 3 + Vite). Tell the compiler that the <cartonization-*> tags are custom elements:
vue({
template: {
// Tell the Vue compiler that <cartonization-*> are custom elements, not Vue components.
compilerOptions: { isCustomElement: tag => tag.startsWith('cartonization-') },
},
}),Set result with .prop (an object can't be an attribute) and listen with @cw-.... Keep the response in a shallowRef, so
the components get the object itself rather than a deep reactive proxy:
<!-- .prop sets the DOM property: an object can't be an attribute. Setting it on one element of the group updates the group. -->
<cartonization-3d-view id="station-3d" group="station" :result.prop="packing"/>
<cartonization-containers id="station-containers" group="station" @cw-container-select="onContainerSelect"/>
<!-- Created when opened: components attached later show the group's current state. -->
<section v-if="infoOpen" class="info">
<cartonization-summary id="info-summary" group="station"/>
<cartonization-ranking id="info-ranking" group="station"/>
</section>
<section class="solo">
<h2>3D only (standalone, container 1)</h2>
<cartonization-3d-view id="solo-3d" container="1" :result.prop="packing"/>
</section>// The user clicked Cartonize: your backend packs the order, and the response goes to the components unchanged.
async function cartonize() {
status.value = { state: 'loading', text: 'Cartonizing...' };
try {
const res = await fetch(`${BACKEND_URL}/api/cartonize`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
orderId: orderId.value.trim(),
lines: lines.value.map(l => ({ sku: l.sku.trim(), quantity: l.quantity })).filter(l => l.sku !== ''),
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`);
}
// A new result selects the best solution and container #1 in the whole group.
const result = body as PackResponse;
packing.value = result;
const first = result.best.containers[0];
selected.value = first ? `Pack container #1: ${first.containerId}` : 'Nothing to pack.';
status.value = { state: 'done', text: `Packed ${result.best.metrics.packedUnits} units into ${result.best.metrics.containerCount} container(s).` };
}
catch (err) {
// Clear the components, so they don't keep showing the previous order's packing.
packing.value = null;
selected.value = 'No packing.';
status.value = { state: 'error', text: `Could not cartonize: ${(err as Error).message}` };
}
}
function onContainerSelect(event: Event) {
const { detail } = event as CartonizationEventMap['cw-container-select'];
selected.value = `Pack container #${detail.index}: ${detail.containerId}`;
}Other frameworks#
The components are standard custom elements, so any framework works the same way: load the script once, let the framework
pass custom elements through untouched, set result as a DOM property (never an attribute), set group and container as
attributes, and listen for cw-solution-select / cw-container-select with addEventListener or the framework's event
binding. The declarations have the full API.