Cartonization 1.0.0

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:

html
<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:

bash
curl -s https://cartonization.internal.example/widget/1.0.0/cartonization-components.js | openssl dgst -sha384 -binary | openssl base64 -A

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:

html
<!-- 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:

html
<!-- 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:

js
// 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;
    }
});

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:

js
// 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:

js
// 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
css
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#

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:

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-...):

html
<!-- 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:

ts
/** 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:

tsx
// 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 result through 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 writes result="[object Object]" as an attribute. A ref sets the property whenever the element is defined. Add the events with addEventListener for the same reason.

tsx
// `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);
}, []);
tsx
<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:

tsx
/** 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:

ts
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:

html
<!-- .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>
ts
// 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.