Titanic Unit Mosaic
The headline Atom unit mosaic: Titanic passengers as dots, blocked into class × sex rows and survived columns, where each count-proportional cell is filled one dot per passenger and colored by survival.
ts
import { Color, chart, circle, derive, spread } from "gofish-graphics";
import { chunk, groupBy, orderBy } from "lodash";
import { titanicPassengers } from "./dataset";
const DOTS_PER_ROW = 34;
const rowsByGroup = new Map<string, number>(
Object.entries(groupBy(titanicPassengers, (p) => `${p.pclass}|${p.sex}`)).map(
([key, rows]) => [key, Math.max(1, Math.round(rows.length / DOTS_PER_ROW))]
)
);
const mosaicPassengers = orderBy(
titanicPassengers.map((p) => ({
...p,
gridRows: rowsByGroup.get(`${p.pclass}|${p.sex}`) ?? 1,
})),
["pclass", "sex", "survived"],
["asc", "asc", "desc"]
);
const container = document.getElementById("app");
chart(mosaicPassengers, {
color: Color.palette(["#2b8cbe", "#ff8408"]),
axes: false,
})
.flow(
// pclass rows: 1st at the bottom, 3rd at the top
spread({ by: "pclass", dir: "y", spacing: 6, alignment: "start" }),
// sex sub-rows within a class: female bottom, male top
spread({ by: "sex", dir: "y", spacing: 3, alignment: "start" }),
// survived columns: survived (blue) left, died (orange) right
spread({ by: "survived", dir: "x", spacing: 3, alignment: "start" })
)
.mark(
chart()
.flow(
// Fill column-by-column — each column `gridRows` tall — so
// every cell has flush top and bottom edges and only the
// last column is short. (Row-major chunking instead left a
// ragged partial *row* spanning the whole cell width, which
// broke the band boundaries.)
// `chunk(size)` takes a fixed size, and `gridRows` depends on the
// block, so the columns are built as data: each item is now one
// column of dots.
derive((rows: any[]) => chunk(rows, rows[0]?.gridRows ?? 1)),
spread({ spacing: 1, dir: "x" }),
// Each group holds one item, a column of dots: spread its dots.
// TODO(#1128): drop this unwrap of the one-item group.
derive(([dots]) => dots),
spread({ spacing: 1, dir: "y", reverse: true })
)
.mark(circle({ r: 3, fill: "survived" }))
)
.render(container);dataset.ts
ts
/**
* Passenger-level Titanic rows (one person per record) for unit charts.
*
* Source file: `titanicPassengers.json`, generated from:
* https://github.com/intuinno/unit/blob/master/app/data/titanic3.csv
* (raw: `https://raw.githubusercontent.com/intuinno/unit/master/app/data/titanic3.csv`)
*
* Columns include **`pclass`**, **`fare`**, **`survived`**, **`sex`**, **`age`**, etc.
* If a row lacks a usable `fare`, a **cabin-tier proxy** from `class` / `Class`
* is used when present; otherwise `pclass` maps to a default tier. Rows with
* `pclass` outside 1–3 are skipped.
*
* Regenerate JSON: `pnpm --filter gofish-graphics run fetch-titanic-passengers`
*/
const raw = [
{
"pclass": "1",
"survived": "1",
"name": "Allen, Miss. Elisabeth Walton",
"sex": "female",
"age": "29",
"sibsp": "0",
"parch": "0",
"ticket": "24160",
"fare": "211.3375",
"cabin": "B5",
"embarked": "S",
"boat": "2",
"body": "",
"home.dest": "St Louis, MO",
"survived_text": "YES"
},
{
"pclass": "1",
"survived": "1",
"name": "Allison, Master. Hudson Trevor",
"sex": "male",
"age": "0.9167",
"sibsp": "1",
"parch": "2",
"ticket": "113781",
"fare": "151.55",
"cabin": "C22 C26",
"embarked": "S",
"boat": "11",
"body": "",
"home.dest": "Montreal, PQ / Chesterville, ON",
"survived_text": "YES"
},
{
"pclass": "1",
"survived": "0",
"name": "Allison, Miss. Helen Loraine",
"sex": "female",
"age": "2",
"sibsp": "1",
"parch": "2",
"ticket": "113781",
"fare": "151.55",
"cabin": "C22 C26",
"embarked": "S",
"boat": "",
"body": "",
"home.dest": "Montreal, PQ / Chesterville, ON",
"survived_text": "NO"
},
{
"pclass": "1",
"survived": "0",
"name": "Allison, Mr. Hudson Joshua Creighton",
"sex": "male",
"age": "30",
"sibsp": "1",
"parch": "2",
"ticket": "113781",
"fare": "151.55",
"cabin": "C22 C26",
"embarked": "S",
"boat": "",
"body": "135",
"home.dest": "Montreal, PQ / Chesterville, ON",
"survived_text": "NO"
},
{
"pclass": "1",
"survived": "0",
"name": "Allison, Mrs. Hudson J C (Bessie Waldo Daniels)",
"sex": "female",
"age": "25",
"sibsp": "1",
"parch": "2",
"ticket": "113781",
"fare": "151.55",
"cabin": "C22 C26",
"embarked": "S",
"boat": "",
"body": "",
"home.dest": "Montreal, PQ / Chesterville, ON",
"survived_text": "NO"
},
// … 1,304 more rows
];
const rowsRaw = raw as unknown;
const rowsList: Record<string, string>[] = Array.isArray(rowsRaw)
? (rowsRaw as Record<string, string>[])
: ((rowsRaw as { default?: Record<string, string>[] }).default ?? []);
/** Tier weights when CSV has no numeric `fare`. */
const TIER_FARE: Record<string, number> = {
First: 85,
Second: 40,
Third: 14,
Crew: 4,
};
function classToPclass(cls: string): 1 | 2 | 3 | null {
const c = cls.trim();
if (c === "First") return 1;
if (c === "Second") return 2;
if (c === "Third") return 3;
return null;
}
function pclassToClassName(pc: number): string {
if (pc === 1) return "First";
if (pc === 2) return "Second";
return "Third";
}
export type TitanicPassenger = {
survived: 0 | 1;
pclass: 1 | 2 | 3;
sex: string;
/** Ticket price when present; else tier proxy (see module doc). */
fare: number;
class: string;
alive?: string;
age?: string;
};
function parsePassengers(rows: Record<string, string>[]): TitanicPassenger[] {
const out: TitanicPassenger[] = [];
for (const r of rows) {
const clsRaw = (r.class ?? r.Class ?? "").trim();
const pcRaw = r.pclass;
const pcNum =
pcRaw !== undefined && pcRaw !== "" && !Number.isNaN(Number(pcRaw))
? Number(pcRaw)
: NaN;
const pcFromClass = classToPclass(clsRaw);
const pc = Number.isFinite(pcNum)
? pcNum
: pcFromClass !== null
? pcFromClass
: NaN;
if (pc !== 1 && pc !== 2 && pc !== 3) continue;
const cls = clsRaw || pclassToClassName(pc);
const fareCell = r.fare ?? r.Fare;
const fareNum =
fareCell !== undefined && fareCell !== "" ? Number(fareCell) : Number.NaN;
const fare =
Number.isFinite(fareNum) && fareNum >= 0
? fareNum
: (TIER_FARE[cls] ?? 1);
const surv = (r.survived ?? r.Survived ?? "").toString().trim();
const st = (r.survived_text ?? r.survivedText ?? "").toString().trim();
const lived =
surv === "1" ||
surv === "Yes" ||
surv === "yes" ||
surv === "true" ||
st.toUpperCase() === "YES"
? 1
: 0;
const sex = (r.sex ?? r.Sex ?? "").trim();
const age = (r.age ?? r.Age ?? "").trim() || undefined;
const aliveRaw = (r.alive ?? r.Alive ?? surv ?? st)
.toString()
.toLowerCase();
out.push({
survived: lived,
pclass: pc as 1 | 2 | 3,
sex,
fare,
class: cls,
alive: aliveRaw === "yes" || aliveRaw === "1" ? "yes" : "no",
age,
});
}
return out;
}
export const titanicPassengers: TitanicPassenger[] = parsePassengers(rowsList);