SizeAdvisor
Takes measurements in cm or inches and a size chart, recommends a size per garment, says how it fits by measurement, and settles between-sizes by a stated preference.
Demo
Find your size
- MShirtBetween S and M. For a regular fit, M.
- Chest: 1.5 cm loose
- Waist: 1.0 cm loose
- 30Jeans30 fits. Based on waist: add inside leg to be surer.
- Waist: fits
- ?CapAdd head to get a size.
Inches first
- Tee bad: the chest figures cannot be read, so they are left out.
- A size of Tee has no name, so it is left out.
- A chart without an id or sizes is left out.
- STeeBetween S and M. For a snug fit, S.
- Chest: 0.7 in tight
Change something to see on_change.
No charts
No size chart to go by.
Get it
It needs theme.qwe, as every component does; or take the whole kit, quillo-ui.qwe. Each component brings its own styles to the pages that show it.
$ curl -O https://quillolang.org/components/ui/theme.qwe$ curl -O https://quillolang.org/components/ui/size_advisor.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/size_advisor.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/size_advisor.qwe
let size_advisor_demo_charts = [ { id: "shirt", label: "Shirt", sizes: [ { name: "S", chest: [86, 91], waist: [71, 76] }, { name: "M", chest: [94, 99], waist: [79, 84] }, { name: "L", chest: [102, 107], waist: [87, 92] }, { name: "XL", chest: [110, 115], waist: [95, 100] }] }, { id: "jeans", label: "Jeans", unit: "in", sizes: [ { name: "30", waist: "30-31", inside_leg: "30-32" }, { name: "32", waist: "32-33", inside_leg: "30-32" }, { name: "34", waist: "34-35", inside_leg: "31-33" }] }, { id: "cap", label: "Cap", sizes: [ { name: "S/M", head: [54, 58] }, { name: "L/XL", head: [57, 61] }] }]component SizeAdvisorDemoLive() { state seen = null <div.demo-stack> <SizeAdvisor id="size-advisor-demo-live" title="Inches first" unit="in" fit="snug" name="me" values={{ chest: 36.5 }} charts={[{ id: "tee", label: "Tee", sizes: [{ name: "S", chest: [86, 91] }, { name: "M", chest: [94, 99] }, { name: "bad", chest: "wide" }, "no"] }, { id: "none" }]} on_change={(f) => { seen = f }}/> <p.demo-note>{seen == null ? "Change something to see on_change." : `on_change: ${seen.unit}, ${seen.fit}, tee ${seen.sizes.tee}.`}</p> </div>}view SizeAdvisorDemo() = <div.demo-stack> <SizeAdvisor id="size-advisor-demo" charts={size_advisor_demo_charts} values={{ chest: 92.5, waist: 78 }} measures={[{ id: "chest", label: "Chest" }, { id: "waist", label: "Waist" }, { id: "inside_leg" }, { id: "head", label: "Head" }]}/> <SizeAdvisorDemoLive/> <SizeAdvisor id="size-advisor-demo-empty" title="No charts" name="empty" charts={[]}/> </div>Source
ui/size_advisor.qwe
// SizeAdvisor: takes body measurements in centimetres or inches and a size chart for// each kind of garment, and recommends a size for each, saying how it fits measure// by measure. A size is sure when every measurement falls inside it. Between two// sizes, in a gap in the chart or where measurements point different ways, the// preference decides: snug takes the smaller, relaxed the larger, and regular the// one that misses by least, going up on a tie. A body past the chart gets the// nearest size and is told so, a chart can be in other units than the reader, and// a number that only makes sense in the other unit is pointed out. Switching unit// keeps the exact measurement, so going to inches and back changes nothing.//// <SizeAdvisor unit="cm" charts={[// { id: "shirt", label: "Shirt", unit: "cm", sizes: [// { name: "S", chest: [86, 91], waist: [71, 76] },// { name: "M", chest: [94, 99], waist: [79, 84] }] },// { id: "jeans", label: "Jeans", unit: "in", sizes: [// { name: "30", waist: "30-31" }, { name: "32", waist: "32-33" }] }]}/>//// charts: a list of { id, label, unit, sizes }: unit "cm" (the default) or "in" for// the chart's own figures; sizes in order, smallest first, each { name, ... } with a// [low, high] pair, text "low-high" or one number for each measure. measures: a// list of { id, label } naming the fields and their order; left out, one for each// measure the charts use, labelled from its id. values: where the fields start, a// map from measure to a number, in `unit`. unit: "cm" or "in" for the fields ("cm");// the reader can switch it. fit: "snug", "regular" (the default) or "relaxed"; the// reader can switch it. title: the heading ("Find your size"). id: a prefix for// its ids ("size-advisor"), unique on the page. The fields are named after their// measure, with unit and fit, so a form around it sends them; name: a prefix for// those names, which two on one page need to keep their radios apart. on_change({ unit, fit, sizes, advice }): inside one of your components,// after each change: sizes a map from chart id to the size's name (null for none),// advice what size_advisor_advise gives for each chart.//// size_advisor_model(charts, measures) reads the data, and size_advisor_advise(chart,// body, fit) advises for a program's own use, with body in centimetres as decimals.// It gives { id, label, size, sure, between, past, rows, used, missing }.import ui/theme.qwe use ui_clslet size_advisor_css = css`.ui-sizeadv { display: grid; gap: 14px; min-width: 0; max-width: 680px; padding: 20px 22px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.45 var(--ui-font); }.ui-sizeadv .ui-sizeadv-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-sizeadv-controls { display: flex; flex-wrap: wrap; gap: 8px 24px; }.ui-sizeadv-choice { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 12px; margin: 0; padding: 0; border: 0; }.ui-sizeadv-choice legend { float: left; margin-right: 8px; padding: 0; font-size: 13px; font-weight: 500; }.ui-sizeadv-choice label { display: inline-flex; align-items: center; gap: 5px; font-size: 13px; cursor: pointer; }.ui-sizeadv-choice input { margin: 0; accent-color: var(--ui-accent); }.ui-sizeadv-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px 12px; }.ui-sizeadv-field { display: grid; gap: 4px; min-width: 0; align-content: start; }.ui-sizeadv-field label { font-size: 13px; font-weight: 500; }.ui-sizeadv-box { box-sizing: border-box; display: flex; align-items: center; gap: 6px; height: 38px; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); transition: border-color .15s, box-shadow .15s; }.ui-sizeadv-box:focus-within { border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-sizeadv-box input { flex: 1; width: 0; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: transparent; color: var(--ui-fg); font: 14px var(--ui-font); text-align: right; font-variant-numeric: tabular-nums; }.ui-sizeadv-box span { flex: none; color: var(--ui-muted); font-size: 12px; font-weight: 600; }.ui-sizeadv-bad .ui-sizeadv-box { border-color: var(--ui-danger); }.ui-sizeadv-error { margin: 0; color: var(--ui-danger); font-size: 12px; }.ui-sizeadv-hint { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin: 0; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-warning-soft); color: var(--ui-warning); font-size: 13px; }.ui-sizeadv-switch { padding: 3px 10px; border: 1px solid currentColor; border-radius: var(--ui-radius-sm); background: transparent; color: inherit; font: 500 12px var(--ui-font); cursor: pointer; }.ui-sizeadv-switch:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-sizeadv-results { display: grid; gap: 10px; margin: 0; padding: 0; list-style: none; }.ui-sizeadv-card { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 2px 14px; padding: 12px 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-subtle); }.ui-sizeadv-size { grid-row: span 3; display: grid; place-items: center; min-width: 52px; min-height: 52px; padding: 0 8px; border-radius: var(--ui-radius-sm); background: var(--ui-accent-soft); color: var(--ui-accent); font-size: 18px; font-weight: 700; }.ui-sizeadv-none { background: var(--ui-surface); color: var(--ui-muted); }.ui-sizeadv-garment { font-weight: 650; }.ui-sizeadv-say { color: var(--ui-fg); font-size: 13px; }.ui-sizeadv-fit { display: flex; flex-wrap: wrap; gap: 2px 12px; margin: 0; padding: 0; list-style: none; font-size: 12px; color: var(--ui-muted); }.ui-sizeadv-tight { color: var(--ui-danger); }.ui-sizeadv-loose { color: var(--ui-warning); }.ui-sizeadv-good { color: var(--ui-success); }.ui-sizeadv-problems { margin: 0; padding: 8px 12px 8px 26px; border-radius: var(--ui-radius-sm); background: var(--ui-warning-soft); color: var(--ui-warning); font-size: 12px; }@media (max-width: 420px) { .ui-sizeadv { padding: 16px; } }@media (prefers-reduced-motion: reduce) { .ui-sizeadv-box { transition: none; } }`// A figure above 0 as a decimal, null when blank, an error value when it is not one.fn size_advisor_read(x) { let t = trim(str(x ?? "")) if t == "" { return null } if !re.test(t, "^[0-9]{1,6}(\\.[0-9]{1,6})?$") { return err("is not a number") } dec(t)}fn size_advisor_to_cm(x, unit: str) = unit == "in" ? x * 2.54m : xfn size_advisor_from_cm(x, unit: str) = unit == "in" ? x / 2.54m : x// A chart's [low, high], "low-high" or one number, in centimetres, or null.fn size_advisor_range(x, unit: str) { let parts = type_of(x) == "list" ? x : re.split(str(x ?? ""), "\\s*[-–]\\s*") if len(parts) < 1 || len(parts) > 2 { return null } let lo = size_advisor_read(parts[0]) let hi = size_advisor_read(parts[len(parts) - 1]) if lo == null || hi == null || is_err(lo) || is_err(hi) || lo > hi { return null } { lo: size_advisor_to_cm(lo, unit), hi: size_advisor_to_cm(hi, unit) }}// A measure's label from its id: "inside_leg" is "Inside leg".fn size_advisor_label(id: str) -> str { let t = re.replace(id, "[_-]+", " ") upper(slice(t, 0, 1)) + slice(t, 1)}// The charts as data it can trust: { measures, charts, problems }, every figure in cm.fn size_advisor_model(charts, measures) { let problems = [] let tidy = [] let named = type_of(measures) == "list" ? measures | filter((m) => type_of(m) == "map" && m.id != null) | map((m) => { id: str(m.id), label: str(m.label ?? size_advisor_label(str(m.id))) }) : [] let found = named for c in type_of(charts) == "list" ? take(charts, 30) : [] { if type_of(c) != "map" || c.id == null || type_of(c.sizes) != "list" || len(c.sizes) == 0 { problems = problems | push("A chart without an id or sizes is left out.") } else { let unit = c.unit == "in" ? "in" : "cm" let label = str(c.label ?? c.id) let sizes = [] for s in take(c.sizes, 40) { if type_of(s) != "map" || s.name == null { problems = problems | push(`A size of ${label} has no name, so it is left out.`) } else { let ranges = {} for k in keys(s) { if k != "name" { let r = size_advisor_range(s[k], unit) if r == null { problems = problems | push(`${label} ${str(s.name)}: the ${k} figures cannot be read, so they are left out.`) } else { ranges = set(ranges, k, r) if !any(found, (m) => m.id == k) { found = found | push({ id: k, label: size_advisor_label(k) }) } } } } sizes = sizes | push({ name: str(s.name), ranges }) } } if len(sizes) == 0 { problems = problems | push(`${label} has no sizes it can read, so it is left out.`) } else { let ids = unique(flatten(sizes | map((s) => keys(s.ranges)))) tidy = tidy | push({ id: str(c.id), label, sizes, ids }) } } } { measures: found, charts: tidy, problems }}// How far a body figure is outside a range: below 0 loose, above 0 tight, 0 fits.fn size_advisor_miss(v, r) { if v > r.hi { return v - r.hi } if v < r.lo { return v - r.lo } 0m}// Worst misses of a size: { tight, loose } as amounts of 0 or more.fn size_advisor_worst(size, used, body) { let misses = used | filter((m) => has(size.ranges, m)) | map((m) => size_advisor_miss(body[m], size.ranges[m])) { tight: max(misses + [0m]), loose: 0m - min(misses + [0m]) }}// The whole of the advice for one chart: see the top of the file.fn size_advisor_advise(chart, body, fit: str) { let used = chart.ids | filter((m) => get(body, m) != null) let missing = chart.ids | filter((m) => get(body, m) == null) let base = { id: chart.id, label: chart.label, used, missing, size: null, sure: false, between: null, past: null, rows: [] } if len(used) == 0 { return base } let n = len(chart.sizes) let worst = chart.sizes | map((s) => size_advisor_worst(s, used, body)) // A size with none of these figures says nothing either way. let at = range(n) | filter((i) => any(used, (m) => has(chart.sizes[i].ranges, m))) let no_tight = at | filter((i) => worst[i].tight == 0) let no_loose = at | filter((i) => worst[i].loose == 0) let above = len(no_tight) == 0 let below = len(no_loose) == 0 let low = above ? last(at) : first(no_tight) let high = below ? first(at) : last(no_loose) // low is the smallest that is not tight and high the largest that is not loose: // low past high means no size does both, and those from high to low are the choice. let first_i = low < high ? low : high let last_i = low < high ? high : low let centre = (i) => { let s = chart.sizes[i] sum(used | filter((m) => has(s.ranges, m)) | map((m) => abs(body[m] * 2 - s.ranges[m].lo - s.ranges[m].hi))) + 0m } let score = (i) => low < high ? centre(i) : max([worst[i].tight, worst[i].loose]) let regular = reduce(at | filter((i) => i >= first_i && i <= last_i), first_i, (b, i) => score(i) <= score(b) ? (low < high && score(i) == score(b) ? b : i) : b) let pick = fit == "snug" ? first_i : fit == "relaxed" ? last_i : regular let size = chart.sizes[pick] let rows = used | filter((m) => has(size.ranges, m)) | map((m) => ({ id: m, by: size_advisor_miss(body[m], size.ranges[m]) })) merge(base, { size: size.name, sure: first_i == last_i && !above && !below, rows, between: first_i != last_i ? [chart.sizes[first_i].name, chart.sizes[last_i].name] : null, past: above ? "above" : below ? "below" : null, overlap: low < high })}// Figures that only make sense in the other unit: the unit to suggest, or null.fn size_advisor_other_unit(model, m: str, v, unit: str) { let ranges = flatten(model.charts | map((c) => c.sizes | filter((s) => has(s.ranges, m)) | map((s) => s.ranges[m]))) if len(ranges) == 0 { return null } let lo = min(ranges | map((r) => r.lo)) let hi = max(ranges | map((r) => r.hi)) let in_cm = size_advisor_to_cm(v, unit) let flipped = unit == "cm" ? v * 2.54m : v let near = (x) => x * 10 >= lo * 7 && x * 10 <= hi * 13 if near(in_cm) || !near(flipped) { return null } unit == "cm" ? "in" : "cm"}fn size_advisor_body(texts, unit: str, measures) { reduce(measures, {}, (b, m) => { let r = size_advisor_read(texts[m.id]) set(b, m.id, r == null || is_err(r) || r == 0 || size_advisor_to_cm(r, unit) > 400 ? null : size_advisor_to_cm(r, unit)) })}component SizeAdvisor(charts = [], measures = null, values = null, unit: str = "cm", fit: str = "regular", title: str = "Find your size", id: str = "size-advisor", name: str? = null, on_change = null, class: str? = null) { style global size_advisor_css let model = size_advisor_model(charts, measures) let start_unit = unit == "in" ? "in" : "cm" let start = reduce(model.measures, {}, (t, m) => set(t, m.id, type_of(values) == "map" ? str(get(values, m.id) ?? "") : "")) state texts = start state body = size_advisor_body(start, start_unit, model.measures) state shown_unit = start_unit state pref = fit == "snug" || fit == "relaxed" ? fit : "regular" let named = (n: str) => name != null ? `${name}_${n}` : n let advice = model.charts | map((c) => size_advisor_advise(c, body, pref)) let label_of = (i: str) => (model.measures | find((m) => m.id == i)).label let length = (x) => `${str(round(size_advisor_from_cm(abs(x), shown_unit), 1, "half_up"))} ${shown_unit}` let field_error = (i: str) => { let r = size_advisor_read(texts[i]) if r == null { return null } if is_err(r) { return `${label_of(i)} ${r.error}.` } if r == 0 { return `${label_of(i)} must be more than 0.` } if size_advisor_to_cm(r, shown_unit) > 400 { return `${label_of(i)} is too large to be a measurement.` } null } let doubts = model.measures | filter((m) => get(body, m.id) != null && field_error(m.id) == null) | filter((m) => size_advisor_other_unit(model, m.id, size_advisor_read(texts[m.id]), shown_unit) != null) let other = shown_unit == "cm" ? "in" : "cm" let other_word = shown_unit == "cm" ? "inches" : "centimetres" let tell = (b, u: str, p: str) => { if on_change != null { let a = model.charts | map((c) => size_advisor_advise(c, b, p)) on_change({ unit: u, fit: p, advice: a, sizes: reduce(a, {}, (m, x) => set(m, x.id, x.size)) }) } } let edit = (i: str, text: str) => { let t = set(texts, i, text) let b = size_advisor_body(t, shown_unit, model.measures) texts = t body = b tell(b, shown_unit, pref) } // The measurements stay; only how they are written changes. let convert = (u: str) => { texts = reduce(model.measures, texts, (t, m) => { let cm = get(body, m.id) cm == null ? t : set(t, m.id, str(round(size_advisor_from_cm(cm, u), 1, "half_up"))) }) shown_unit = u tell(body, u, pref) } // The numbers stay and were in the other unit all along. let retake = () => { let b = size_advisor_body(texts, other, model.measures) body = b shown_unit = other tell(b, other, pref) } let say = (a) => { if a.size == null { return `Add ${a.missing | map((m) => lower(label_of(m))) | join(" or ")} to get a size.` } let tail = len(a.missing) > 0 ? ` Based on ${a.used | map((m) => lower(label_of(m))) | join(", ")}: add ${a.missing | map((m) => lower(label_of(m))) | join(" and ")} to be surer.` : "" if a.past == "above" { return `Above this chart: ${a.size} is the largest, and it will be tight.${tail}` } if a.past == "below" { return `Below this chart: ${a.size} is the smallest, and it will be loose.${tail}` } if a.between != null && a.overlap == true { return `Both ${a.between[0]} and ${a.between[1]} fit. For a ${pref} fit, ${a.size}.${tail}` } if a.between != null { return `Between ${a.between[0]} and ${a.between[1]}. For a ${pref} fit, ${a.size}.${tail}` } `${a.size} fits.${tail}` } let fit_text = (r) => r.by == 0 ? "fits" : r.by > 0 ? `${length(r.by)} tight` : `${length(r.by)} loose` <section class={ui_cls(["ui-sizeadv", class])} aria-labelledby={`${id}-title`}> <h3.ui-sizeadv-title id={`${id}-title`}>{title}</h3> {len(model.problems) > 0 && <ul.ui-sizeadv-problems>{for p in model.problems => <li>{p}</li>}</ul>} <div.ui-sizeadv-controls> <fieldset.ui-sizeadv-choice> <legend>Measure in</legend> <label><input type="radio" name={named("unit")} value="cm" checked={shown_unit == "cm"} @change={(e) => { convert("cm") }}/> cm</label> <label><input type="radio" name={named("unit")} value="in" checked={shown_unit == "in"} @change={(e) => { convert("in") }}/> inches</label> </fieldset> <fieldset.ui-sizeadv-choice> <legend>Fit</legend> {for f in ["snug", "regular", "relaxed"] => <label key={f}><input type="radio" name={named("fit")} value={f} checked={pref == f} @change={(e) => { pref = f; tell(body, shown_unit, f) }}/> {upper(slice(f, 0, 1)) + slice(f, 1)}</label>} </fieldset> </div> <div.ui-sizeadv-fields> {for m in model.measures => <div key={m.id} class={ui_cls(["ui-sizeadv-field", field_error(m.id) != null && "ui-sizeadv-bad"])}> <label for={`${id}-${m.id}`}>{m.label}</label> <div.ui-sizeadv-box> <input type="text" inputmode="decimal" id={`${id}-${m.id}`} name={named(m.id)} value={texts[m.id]} autocomplete="off" aria-invalid={field_error(m.id) != null ? "true" : null} aria-describedby={field_error(m.id) != null ? `${id}-${m.id}-error` : null} @input={(e) => { edit(m.id, e.value) }}/> <span aria-hidden="true">{shown_unit}</span> </div> {field_error(m.id) != null && <p.ui-sizeadv-error id={`${id}-${m.id}-error`}>{field_error(m.id)}</p>} </div>} </div> <div aria-live="polite"> {len(doubts) > 0 && <p.ui-sizeadv-hint> <span>{doubts | map((m) => m.label) | join(", ")} {len(doubts) == 1 ? "looks" : "look"} like {other_word}.</span> <button.ui-sizeadv-switch type="button" @click={() => { retake() }}>Read these as {other_word}</button> </p>} </div> {len(model.charts) == 0 && <p.ui-sizeadv-error role="alert">No size chart to go by.</p>} <ul.ui-sizeadv-results aria-live="polite"> {for a in advice => <li.ui-sizeadv-card key={a.id}> <span class={ui_cls(["ui-sizeadv-size", a.size == null && "ui-sizeadv-none"])}>{a.size ?? "?"}</span> <span.ui-sizeadv-garment>{a.label}</span> <span.ui-sizeadv-say>{say(a)}</span> <ul.ui-sizeadv-fit> {for r in a.rows => <li key={r.id} class={r.by == 0 ? "ui-sizeadv-good" : r.by > 0 ? "ui-sizeadv-tight" : "ui-sizeadv-loose"}>{label_of(r.id)}: {fit_text(r)}</li>} </ul> </li>} </ul> </section>}Related
BestCouponWorks out the lowest total from several coupons, trying each combination and order under their rules, and explains the winner and why each other coupon lost.3 views · 7 downloadsBoxPackerPicks the smallest box that takes items with rotation, or splits across boxes, works out dimensional and billable weight, and says when an item fits none.4 views · 7 downloadsBundleBuilderPicks a bundle from groups with minimums and maximums: tiered discount, the cheapest way to the next tier, the saving over buying alone, and totals exact to the cent.4 views · 6 downloadsFreeShippingMeterHow far a cart is from free shipping or the next reward, and the cheapest item or set that gets it there, with tiers per currency, exact when reached.3 views · 5 downloadsMarginCalcCost, price, margin and markup bound together: edit any and the rest follow from the two set last, with a price for a target margin and a warning under cost.3 views · 6 downloadsPlanAdvisorUsage sliders against plans with included amounts, overage and an annual discount: the actual cost of each, the cheapest, and at what usage the answer changes.5 views · 5 downloads
Thread
Thread 0 posts
On SizeAdvisor: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.