TeamBalancer
Splits people into teams balanced by rating, with keep-together and keep-apart rules, shows each total and the spread, and says when the rules cannot be met.
Demo
Saturday five-a-side
Spread 0.5 between the strongest and weakest team, 16.83 on average. All 2 rules are kept.
Team 1
17- Ada9
- Di6
- Ivy2
Team 2
17- Cy7
- Ed6
- Gus4
Team 3
16.5- Bo8.5
- Flo5
- Hal3
Six players
Spread 1 between the strongest and weakest team, 10.50 on average. The 1 rule is kept.
Reds
11- Ann6
- Cal4
- Fay1
Blues
10- Ben5
- Dee3
- Eli2
Change it to see on_change.
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/team_balancer.qwemain.qwe
import ui/theme.qwe use theme_cssuse ui/team_balancer.qweroute GET / = <html><head><style>{theme_css}</style></head><body class="ui-root">...</body></html>Example
The code of the preview above.
demos/team_balancer.qwe
let team_balancer_demo_people = "Ada, 9\nBo, 8.5\nCy, 7\nDi, 6\nEd, 6\nFlo, 5\nGus, 4\nHal, 3\nIvy, 2"component TeamBalancerDemoLive() { state seen = null <div.demo-stack> <TeamBalancer id="team-balancer-demo-six" title="Six players" name="squad" team_names={["Reds", "Blues"]} people={[{ name: "Ann", rating: 6 }, { name: "Ben", rating: 5 }, { name: "Cal", rating: 4 }, { name: "Dee", rating: 3 }, { name: "Eli", rating: 2 }, { name: "Fay", rating: 1 }]} together={[["Ann", "Fay"]]} on_change={(r) => { seen = r }}/> <p.demo-note>{seen == null ? "Change it to see on_change." : seen.ok ? `on_change: a spread of ${str(seen.spread)}.` : "on_change: no split."}</p> </div>}view TeamBalancerDemo() = <div.demo-stack> <TeamBalancer id="team-balancer-demo" title="Saturday five-a-side" people={team_balancer_demo_people} teams={3} together="Ada, Ivy" apart="Bo, Cy"/> <TeamBalancerDemoLive/> </div>Source
ui/team_balancer.qwe
// TeamBalancer: splits people into teams even in number and as even in strength as it// can, while keeping who must stay together together and who must stay apart apart.// It searches for a split that keeps every rule, then swaps people between teams// while that narrows the gap, from several starts, and shows each team's total and the// spread between the strongest and weakest. Reshuffle gives another split under the// same rules. If the rules cannot be met it says which and why, rather than bending one.//// <TeamBalancer people="Ada, 8\nBo, 7\nCy, 5\nDi, 4" teams={2}/>// <TeamBalancer people={[{ name: "Ada", rating: 8 }, { name: "Bo", rating: 7.5 }]} together={[["Ada", "Bo"]]}// on_change={(r) => { spread = r.spread }}/>//// people: a person a line, name then rating ("Ada, 8", "Ada 8" or "Ada: 8.5"), or a list// of { name, rating }. A rating is a number of 0 or more; a line with none takes// default_rating (5) and says so. teams: how many teams (2). together, apart: rules, a// line each in text or a list of lists; the names on a line stay on one team, or// all on different teams. A name that is not on the list is reported and left out of// the rule. team_names: the teams' names, a list ("Team 1", "Team 2", ...). seed: where// the shuffling starts (0), so a page is the same on the server and in the browser;// reshuffles go on from it. title: the heading ("Balance teams"). id: a prefix for its// ids ("team-balancer"), unique on the page. The fields are named people, teams,// together and apart, with the split as JSON in result; name: a prefix for those.// on_change(r): inside one of your components, given the result below after each change.//// team_balancer_solve(people, together, apart, count, seed, team_names) is the work, for a// program's own use, with team_balancer_read and team_balancer_rules to read the text. It gives// { ok, reason, teams, spread, mean }: each team { name, members, total, count }, ratings// and totals as decimals. It takes 120 people and 20 teams, and a search of bounded length,// and says when it stopped there, as that is not the same as the rules being impossible.import ui/theme.qwe use ui_clslet team_balancer_css = css`.ui-teambal { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; min-width: 0; max-width: 720px; 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-teambal .ui-teambal-title { display: block; margin: 0; font-size: 16px; font-weight: 650; }.ui-teambal-inputs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }.ui-teambal-field { display: grid; gap: 5px; align-content: start; min-width: 0; font-size: 13px; font-weight: 500; }.ui-teambal textarea, .ui-teambal-count input { box-sizing: border-box; width: 100%; min-width: 0; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 14px/1.5 var(--ui-font); }.ui-teambal textarea { min-height: 110px; padding: 8px 10px; resize: vertical; }.ui-teambal-count { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }.ui-teambal-count label { display: grid; gap: 5px; width: 110px; font-size: 13px; font-weight: 500; }.ui-teambal-count input { height: 36px; }.ui-teambal textarea:focus-visible, .ui-teambal-count input:focus-visible { outline: none; border-color: var(--ui-accent); box-shadow: var(--ui-focus); }.ui-teambal-count input[aria-invalid] { border-color: var(--ui-danger); }.ui-teambal-shuffle { height: 36px; padding: 0 14px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); background: var(--ui-surface); color: var(--ui-fg); font: 500 13px var(--ui-font); cursor: pointer; }.ui-teambal-shuffle:hover:not(:disabled) { border-color: var(--ui-strong); background: var(--ui-subtle); }.ui-teambal-shuffle:focus-visible { outline: none; box-shadow: var(--ui-focus); }.ui-teambal-shuffle:disabled { opacity: .5; cursor: not-allowed; }.ui-teambal-problems { display: grid; gap: 2px; margin: 0; padding: 0; list-style: none; color: var(--ui-warning); font-size: 13px; }.ui-teambal-error { margin: 0; padding: 10px 14px; border-radius: var(--ui-radius-sm); background: var(--ui-danger-soft); color: var(--ui-danger); font-weight: 500; }.ui-teambal-summary { margin: 0; padding: 8px 12px; border-radius: var(--ui-radius-sm); background: var(--ui-subtle); font-weight: 600; font-variant-numeric: tabular-nums; }.ui-teambal-teams { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin: 0; padding: 0; list-style: none; }.ui-teambal-team { display: grid; gap: 4px; align-content: start; padding: 10px 12px; border: 1px solid var(--ui-border); border-radius: var(--ui-radius-sm); }.ui-teambal-head { display: flex; justify-content: space-between; gap: 8px; }.ui-teambal .ui-teambal-name { margin: 0; font-size: 14px; font-weight: 650; }.ui-teambal-total { font-weight: 650; font-variant-numeric: tabular-nums; }.ui-teambal-members { display: grid; gap: 1px; margin: 0; padding: 0; list-style: none; font-size: 13px; }.ui-teambal-members li { display: flex; justify-content: space-between; gap: 8px; }.ui-teambal-members span:last-child { color: var(--ui-muted); font-variant-numeric: tabular-nums; }@media (max-width: 560px) { .ui-teambal-inputs { grid-template-columns: minmax(0, 1fr); } }`let team_balancer_most_people = 120let team_balancer_most_teams = 20let team_balancer_most_steps = 20000let team_balancer_most_swaps = 40000let team_balancer_starts = 6// Park and Miller's generator: its products stay under 2^53, so the browser and the// server, and so the page each makes, agree.fn team_balancer_step(s: int) = s * 48271 % 2147483647fn team_balancer_shuffle(xs: list, seed: int) { let out = xs let s = seed for i in reverse(range(len(out))) { s = team_balancer_step(s) let j = s % (i + 1) let a = out[i] out = set(set(out, i, out[j]), j, a) } out}fn team_balancer_text(people) { if type_of(people) != "list" { return str(people ?? "") } let line = (p) => type_of(p) == "map" ? `${str(p.name ?? "")}${p.rating == null ? "" : `, ${str(p.rating)}`}` : str(p) join(people | map(line), "\n")}fn team_balancer_rule_text(rules) { if type_of(rules) != "list" { return str(rules ?? "") } join(rules | map((r) => type_of(r) == "list" ? join(r | map((x) => str(x)), ", ") : str(r)), "\n")}// People from text: { people, problems }, each person { name, rating }.fn team_balancer_read(text: str, fallback) { let lines = split(replace(text, "\r", ""), "\n") let people = [] let problems = [] for i in 0..len(lines) { let l = trim(lines[i]) if l != "" { let m = re.find(l, "^(.*?)[\\s,:;]+(-?\\d+(\\.\\d+)?)$") let who = trim(m == null ? l : m.groups[0]) let given = m == null ? null : dec(m.groups[1]) if who == "" { problems = problems | push(`Line ${i + 1}: no name.`) } else if any(people, (p) => lower(p.name) == lower(who)) { problems = problems | push(`Line ${i + 1}: ${slice(who, 0, 40)} is already on the list; left out.`) } else if len(people) >= team_balancer_most_people { problems = problems | push(`Line ${i + 1}: only the first ${team_balancer_most_people} people are taken.`) } else { if given == null { problems = problems | push(`Line ${i + 1}: ${slice(who, 0, 40)} has no rating, so counts as ${str(fallback)}.`) } if given != null && given < 0 { problems = problems | push(`Line ${i + 1}: a rating is 0 or more, so ${slice(who, 0, 40)} counts as ${str(fallback)}.`) } people = people | push({ name: slice(who, 0, 60), rating: given == null || given < 0 ? dec(fallback) : given }) } } } let cut = problems | filter((p) => contains(p, "only the first")) { people, problems: (problems | filter((p) => !contains(p, "only the first"))) + (cut | take(1)) }}// Rules from text: { lines, problems }, each line the indexes of the people it names.fn team_balancer_rules(text: str, people: list, label: str) { let lines = split(replace(text, "\r", ""), "\n") let found = [] let problems = [] for i in 0..len(lines) { let names = re.split(lines[i], "[,;+&]") | map((x) => trim(x)) | filter((x) => x != "") let on_line = [] for n in names { let at = range(len(people)) | find((p) => lower(people[p].name) == lower(n)) if at == null { problems = problems | push(`${label}, line ${i + 1}: no one called ${slice(n, 0, 40)} on the list.`) } else if !contains(on_line, at) { on_line = on_line | push(at) } } if len(on_line) > 1 { found = found | push(on_line) } } { lines: found, problems }}// People tied together, as lists of indexes.fn team_balancer_groups(count: int, together: list) { let owner = range(count) for line in together { let keep = owner[line[0]] for p in line { let was = owner[p] owner = owner | map((o) => o == was ? keep : o) } } let ids = owner | unique() ids | map((g) => range(count) | filter((p) => owner[p] == g))}fn team_balancer_names(people: list, members: list) { let names = members | map((p) => people[p].name) let n = len(names) n < 2 ? join(names, "") : `${join(slice(names, 0, n - 1), ", ")} and ${names[n - 1]}`}// One step of the search below: places group `order[i]` and goes on. A top-level function,// as a `fn` inside a function cannot call itself in the browser. Gives { place, steps },// place null when nothing fits from here.fn team_balancer_search(i: int, place: list, size: list, total: list, full: int, steps0: int, p) { if i == len(p.order) { return { place, steps: steps0 } } let steps = steps0 + 1 if steps > team_balancer_most_steps { return { place: null, steps } } let g = p.order[i] let picks = range(p.count) | sort_by((t) => total[t]) let seen_empty = false for t in picks { let empty = size[t] == 0 let after = size[t] + p.sizes[g] let big = p.extra > 0 && after == p.high ? 1 : 0 let room = after <= p.high && (big == 0 || full < p.extra) let free = !any(range(i), (j) => place[p.order[j]] == t && p.clash[g][p.order[j]]) // Empty teams are all alike: trying one is trying them all. if room && free && !(empty && seen_empty) { let r = team_balancer_search(i + 1, set(place, g, t), set(size, t, after), set(total, t, total[t] + p.ratings[g]), full + big, steps, p) if r.place != null || r.steps > team_balancer_most_steps { return r } steps = r.steps } if empty { seen_empty = true } } { place: null, steps }}// A placing of groups on teams that keeps every rule: a depth-first search, weakest team// first, with a bound on its steps. Gives { place } or { failed: "none" | "steps" }.fn team_balancer_place(order: list, sizes: list, ratings: list, clash: list, count: int, high: int, extra: int) { let start = for g in 0..len(order) => -1 let zero = for t in 0..count => 0 let empty = for t in 0..count => 0.0 let r = team_balancer_search(0, start, zero, empty, 0, 0, { order, sizes, ratings, clash, count, high, extra }) r.place != null ? { place: r.place } : { failed: r.steps > team_balancer_most_steps ? "steps" : "none" }}fn team_balancer_cost(total: list) { let mean = sum(total) / len(total) total | map((t) => (t - mean) * (t - mean)) | sum()}fn team_balancer_totals(place: list, ratings: list, count: int) { let total = for t in 0..count => 0.0 for g in 0..len(place) { total = set(total, place[g], total[place[g]] + ratings[g]) } total}// Swaps equal-sized groups, and moves a group where that only exchanges two teams'// sizes, for as long as that narrows the teams, within a bounded number of tries.fn team_balancer_improve(place0: list, sizes: list, ratings: list, clash: list, count: int) { let place = place0 let n = len(place) let total = team_balancer_totals(place, ratings, count) let size = for t in 0..count => 0 for g in 0..n { size = set(size, place[g], size[place[g]] + sizes[g]) } let tries = 0 let better = true // Would group g sit on team t without a clash, not counting group `but`? let fits = (g: int, t: int, but: int) => !any(range(n), (h) => h != g && h != but && place[h] == t && clash[g][h]) while better && tries < team_balancer_most_swaps { better = false for a in 0..n { for b in (a + 1)..n { let ta = place[a] let tb = place[b] if ta != tb && sizes[a] == sizes[b] && tries < team_balancer_most_swaps { tries += 1 let shift = ratings[a] - ratings[b] let gain = (total[ta] - shift - total[ta]) * (total[ta] - shift + total[ta]) + (total[tb] + shift - total[tb]) * (total[tb] + shift + total[tb]) if gain < -0.000000001 && fits(a, tb, b) && fits(b, ta, a) { place = set(set(place, a, tb), b, ta) total = set(set(total, ta, total[ta] - shift), tb, total[tb] + shift) better = true } } } } for g in 0..n { for t in 0..count { let from = place[g] if t != from && size[from] - sizes[g] == size[t] && tries < team_balancer_most_swaps { tries += 1 let r = ratings[g] let gain = (total[from] - r - total[from]) * (total[from] - r + total[from]) + (total[t] + r - total[t]) * (total[t] + r + total[t]) if gain < -0.000000001 && fits(g, t, -1) { place = set(place, g, t) total = set(set(total, from, total[from] - r), t, total[t] + r) let s = size[from] size = set(set(size, from, size[t]), t, s) better = true } } } } } place}fn team_balancer_reason(people: list, apart: list, count: int) { for line in apart { if len(line) > count { return `${team_balancer_names(people, line)} must all be apart, which takes ${len(line)} teams, and there are ${count}.` } } `No split into ${count} even teams keeps every rule: try fewer rules or another number of teams.`}// { ok, reason, teams, spread, mean, signature }. Rules are lists of indexes.fn team_balancer_solve(people: list, together: list, apart: list, count: int, seed: int = 0, team_names = null) { let n = len(people) let refuse = (why: str) => ({ ok: false, reason: why, teams: [], spread: null, mean: null, signature: "" }) if n == 0 { return refuse("Add people to make teams.") } if count < 1 { return refuse("There must be at least one team.") } if count > team_balancer_most_teams { return refuse(`${team_balancer_most_teams} teams is the most this makes.`) } if count > n { return refuse(`There are ${count} teams for ${n == 1 ? "1 person" : `${n} people`}: fewer teams, or more people.`) } let groups = team_balancer_groups(n, together) let total_groups = len(groups) let group_of = for p in 0..n => 0 for g in 0..total_groups { for p in groups[g] { group_of = set(group_of, p, g) } } let clash = for g in 0..total_groups => for h in 0..total_groups => false for line in apart { for x in line { for y in line { if x != y { let a = group_of[x] let b = group_of[y] if a == b { return refuse(`${people[x].name} and ${people[y].name} must be kept together and kept apart: drop one rule.`) } clash = set(clash, a, set(clash[a], b, true)) } } } } let high = int(ceil(n / count)) let extra = n % count for g in groups { if len(g) > high { return refuse(`${team_balancer_names(people, g)} must stay together, but a team holds at most ${high}.`) } } let sizes = groups | map((g) => len(g)) let ratings = groups | map((g) => g | map((p) => float(people[p].rating)) | sum()) let best = null let best_cost = 0.0 let stopped = false for start in 0..team_balancer_starts { let s = (seed * 7919 + start * 104729 + 12345) % 2147483646 + 1 // The biggest groups and those with the most clashes are placed first: they // are the hardest. A shuffle first gives the sort's ties another order. let shuffled = team_balancer_shuffle(range(total_groups), s) let hard = (g) => -(sizes[g] * 1000 + (clash[g] | filter((c) => c) | len) * 100) let order = shuffled | sort_by(hard) let placed = team_balancer_place(order, sizes, ratings, clash, count, high, extra) if placed.failed == "none" { return refuse(team_balancer_reason(people, apart, count)) } if placed.failed == "steps" { stopped = true } else { let place = team_balancer_improve(placed.place, sizes, ratings, clash, count) let totals = team_balancer_totals(place, ratings, count) let cost = team_balancer_cost(totals) if best == null || cost < best_cost - 0.000000001 { best = place best_cost = cost } } } if best == null { return refuse(stopped ? "The search stopped at its limit without finding a split: the rules may still be possible, so try fewer." : "No split keeps every rule.") } let teams = for t in 0..count { let members = range(n) | filter((p) => best[group_of[p]] == t) | sort_by((p) => -float(people[p].rating)) { name: (team_names ?? [])[t] ?? `Team ${t + 1}`, members: members | map((p) => people[p]), total: members | map((p) => people[p].rating) | sum(), count: len(members) } } let totals = teams | map((t) => dec(t.total)) let signature = teams | map((t) => t.members | map((m) => m.name) | sort() | join(",")) | sort() | join("|") { ok: true, reason: "", teams: teams | map((t) => ({ ...t, total: dec(t.total) })), spread: max(totals) - min(totals), mean: sum(totals) / count, signature }}fn team_balancer_count(text) { let t = trim(str(text ?? "")) re.test(t, "^\\d{1,4}$") ? int(t) : 0}fn team_balancer_all(text: str, fallback, together: str, apart: str, count: int, seed: int, team_names) { let read = team_balancer_read(text, fallback) let t = team_balancer_rules(together, read.people, "Keep together") let a = team_balancer_rules(apart, read.people, "Keep apart") { read, problems: read.problems + t.problems + a.problems, rules: len(t.lines) + len(a.lines), result: team_balancer_solve(read.people, t.lines, a.lines, count, seed, team_names) }}fn team_balancer_people(n: int) = n == 1 ? "1 person" : `${n} people`component TeamBalancer(people = "", teams = 2, together = "", apart = "", default_rating = 5, team_names = null, seed: int = 0, title: str = "Balance teams", id: str = "team-balancer", name: str? = null, on_change = null, class: str? = null) { style global team_balancer_css state text = team_balancer_text(people) state count_text = str(teams) state with_text = team_balancer_rule_text(together) state off_text = team_balancer_rule_text(apart) state turn = seed state said = "" let named = (n: str) => name != null ? `${name}_${n}` : n let count = team_balancer_count(count_text) let all_ = team_balancer_all(text, default_rating, with_text, off_text, count, turn, team_names) let r = all_.result let rules_note = all_.rules == 0 ? "" : all_.rules == 1 ? " The 1 rule is kept." : ` All ${all_.rules} rules are kept.` let summary = r.ok ? `Spread ${str(r.spread)} between the strongest and weakest team, ${str(round(r.mean, 2, "half_up"))} on average.${rules_note}` : "" let saved = json.stringify(r.teams | map((t) => ({ name: t.name, members: t.members | map((m) => m.name) }))) let tell = () => { if on_change != null { on_change(team_balancer_all(text, default_rating, with_text, off_text, team_balancer_count(count_text), turn, team_names).result) } } let change = () => { said = "" tell() } // The next seeds until one gives another split; a few people have few splits. let reshuffle = () => { let now = team_balancer_count(count_text) let seen = team_balancer_all(text, default_rating, with_text, off_text, now, turn, team_names).result let tries = 1 let found = null while found == null && tries <= 12 { let next = team_balancer_all(text, default_rating, with_text, off_text, now, turn + tries, team_names).result if next.ok && next.signature != seen.signature { found = turn + tries } tries += 1 } if found == null { said = "No other split came up: with these people and rules this may be the only even one." } else { turn = found said = "Reshuffled, under the same rules." tell() } } <section class={ui_cls(["ui-teambal", class])} aria-labelledby={`${id}-title`}> <h3.ui-teambal-title id={`${id}-title`}>{title}</h3> <div.ui-teambal-inputs> <label.ui-teambal-field> People and ratings, one a line <textarea id={`${id}-people`} name={named("people")} value={text} rows="6" spellcheck="false" @input={(e) => { text = e.value; change() }}></textarea> </label> <label.ui-teambal-field> Keep together, names on a line <textarea id={`${id}-together`} name={named("together")} value={with_text} rows="6" spellcheck="false" @input={(e) => { with_text = e.value; change() }}></textarea> </label> <label.ui-teambal-field> Keep apart, names on a line <textarea id={`${id}-apart`} name={named("apart")} value={off_text} rows="6" spellcheck="false" @input={(e) => { off_text = e.value; change() }}></textarea> </label> </div> <div.ui-teambal-count> <label> Teams <input type="text" inputmode="numeric" id={`${id}-count`} name={named("teams")} value={count_text} autocomplete="off" aria-invalid={count < 1 ? "true" : null} @input={(e) => { count_text = e.value; change() }}/> </label> <button.ui-teambal-shuffle type="button" disabled={!r.ok} @click={reshuffle}>Reshuffle</button> </div> <input type="hidden" name={named("result")} value={saved}/> {len(all_.problems) > 0 && <ul.ui-teambal-problems> {for p in all_.problems | take(8) => <li>{p}</li>} {len(all_.problems) > 8 && <li>{`And ${len(all_.problems) - 8} more.`}</li>} </ul>} {!r.ok && <p.ui-teambal-error role="alert">{r.reason}</p>} {r.ok && <p.ui-teambal-summary aria-live="polite">{summary}</p>} {said != "" && <p role="status">{said}</p>} {r.ok && <ul.ui-teambal-teams> {for t in r.teams { let team = t.name <li.ui-teambal-team key={team}> <div.ui-teambal-head> <h4.ui-teambal-name>{team}</h4> <span.ui-teambal-total>{str(t.total)}</span> </div> <ul.ui-teambal-members> {for m in t.members { let who = m.name <li key={who}><span>{who}</span><span>{str(m.rating)}</span></li> }} </ul> </li> }} </ul>} </section>}Related
TournamentBracketA single-elimination draw from seeds, with byes and correct pairing: clicking winners moves them on, and changing a result clears what rested on it.2 views · 4 downloadsRankedVoteCounts ranked ballots by instant runoff, round by round, showing each transfer and exhausted ballot, with a stated rule for breaking ties.2 views · 9 downloadsRoundRobinFair fixtures by the circle method, with home and away balanced, a bye for an odd number and double rounds, and standings with stated tiebreaks from scores entered.3 views · 6 downloads
Thread
Thread 0 posts
On TeamBalancer: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.