import clsx from 'clsx' import { EDUCATION_CHOICES, INVERTED_DIET_CHOICES, INVERTED_EDUCATION_CHOICES, INVERTED_GENDERS, INVERTED_LANGUAGE_CHOICES, INVERTED_MBTI_CHOICES, INVERTED_ORIENTATION_CHOICES, INVERTED_POLITICAL_CHOICES, INVERTED_RACE_CHOICES, INVERTED_RELATIONSHIP_CHOICES, INVERTED_RELATIONSHIP_STATUS_CHOICES, INVERTED_RELIGION_CHOICES, MBTI_TYPE_NAMES, } from 'common/choices' import {DemographicField, Distribution} from 'common/stats' import {ComponentType, SVGProps} from 'react' import {surface} from 'web/components/widgets/surface' /** * A single profile-field breakdown as a ranked bar list — the building block of the "Who's on Compass" * section of /stats. Deliberately the same bar vocabulary as `country-spread.tsx`: a member scanning the * page reads one distribution the same way as the next, so education, religion and languages all share a * form and only the labels change. * * The raw stored values (`'bachelors'`, `'veg'`, `'intj'`) are turned into human labels here rather than * on the wire, so the payload stays small and the label maps live next to the rest of the choice tables. * * Percentages are always of `dist.base` — the members who answered the field — not of the whole platform. * For a multi-select field that base is the distinct answerers, so a member who picked three diets counts * once in the denominator; that is why the multi-select bars can each be large yet not sum to 100%, and * why the subtitle says so out loud. */ type IconType = ComponentType> // value → English label, per field. Missing fields (age buckets are already human-readable) fall through // to the raw value. Same maps the search filters use, so a label never disagrees between the two places. const LABELS: Partial>> = { gender: INVERTED_GENDERS, education_level: INVERTED_EDUCATION_CHOICES, political_beliefs: INVERTED_POLITICAL_CHOICES, religion: INVERTED_RELIGION_CHOICES, diet: INVERTED_DIET_CHOICES, ethnicity: INVERTED_RACE_CHOICES, orientation: INVERTED_ORIENTATION_CHOICES, pref_relation_styles: INVERTED_RELATIONSHIP_CHOICES, relationship_status: INVERTED_RELATIONSHIP_STATUS_CHOICES, languages: INVERTED_LANGUAGE_CHOICES, } function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) } // Education reads best in level order rather than by popularity — the levels are an ordinal ladder, so // showing PhD above High school (the choices dict, reversed) is clearer than "whichever is most common // first". Everything else keeps the count ranking the backend already sorted it into. const EDUCATION_ORDER: string[] = Object.values(EDUCATION_CHOICES).reverse() function orderItems(field: DemographicField, items: Distribution['items']): Distribution['items'] { if (field !== 'education_level') return items return [...items].sort( (a, b) => EDUCATION_ORDER.indexOf(a.value) - EDUCATION_ORDER.indexOf(b.value), ) } // Exported so the home and about pages label the same raw values the same way this card does — they name a // few of these distributions in prose, and a "Bachelor's" here against a "bachelors" there would be the // kind of drift the shared choices maps exist to prevent. export function labelFor(field: DemographicField, value: string): string { // MBTI reads as the four-letter code plus its archetype — "INTJ" alone is jargon; "INTJ Architect" // tells the reader what it means without a second lookup. if (field === 'mbti') { const code = INVERTED_MBTI_CHOICES[value] ?? value.toUpperCase() const name = MBTI_TYPE_NAMES[code] return name ? `${code} · ${name}` : code } return LABELS[field]?.[value] ?? capitalize(value) } // Exported so the compact mini-charts on the home and about pages (a single field's top values, with no // surrounding card) draw the exact same bar as this one's own rows — one bar vocabulary everywhere a count // is shown as a fraction of a base, rather than a second one invented per page. `rank` defaults to 0 // (no fade) for callers whose bar width is already the true percentage rather than normalised to a leader, // where the fade's job of distinguishing near-equal bars doesn't apply. export function DistRow({ label, pct, widthPct, rank = 0, }: { label: string pct: number widthPct: number rank?: number }) { return (
{label}
{pct}%
) } export function DistributionCard({ field, title, icon: Icon, dist, }: { field: DemographicField title: string icon: IconType dist: Distribution | undefined }) { if (!dist || !dist.items.length) return null // Most fields arrive count-sorted from the backend; education is re-laid into level order here. const items = orderItems(field, dist.items) // Bar widths are relative to the largest bar, not to the base: against the base a field where the top // answer is only 20% would render as a row of stubs and the ranking would be unreadable. Percentages // (the number on the right) are still of the base — only the drawn width is normalised to the leader. // Taken as the true max, not items[0], because age buckets (and education) aren't ordered by size. const max = Math.max(...items.map((it) => it.count)) return (

{title}

{dist.multi ? `of ${dist.base.toLocaleString()} who shared` : `${dist.base.toLocaleString()} shared`}

{items.map((it, i) => ( ))}
) }