Skip to content

← Examples

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);