mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-07-30 17:59:13 -04:00
169 lines
7.0 KiB
TypeScript
169 lines
7.0 KiB
TypeScript
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<SVGProps<SVGSVGElement>>
|
|
|
|
// 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<Record<DemographicField, Record<string, string>>> = {
|
|
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 (
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-24 shrink-0 truncate text-[13px] text-ink-700 sm:w-28" title={label}>
|
|
{label}
|
|
</div>
|
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-canvas-200">
|
|
<div
|
|
className="h-full rounded-full bg-primary-500 transition-[width] duration-700 ease-out"
|
|
// The bar shortens the ranking gently down the list — the top value stays full-strength amber
|
|
// and each lower row loses a little, so the order reads even where two counts are close.
|
|
style={{width: `${widthPct}%`, opacity: Math.max(0.45, 1 - rank * 0.11)}}
|
|
/>
|
|
</div>
|
|
<div className="w-9 shrink-0 text-right text-xs tabular-nums text-ink-500">{pct}%</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className={clsx(surface, 'flex h-full flex-col p-5 sm:p-6')}>
|
|
<div className="mb-4 flex items-center gap-3">
|
|
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-primary-100 ring-1 ring-primary-200">
|
|
<Icon className="h-[18px] w-[18px] text-primary-600" strokeWidth={1.8} />
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h3 className="text-sm font-bold leading-tight text-ink-900">{title}</h3>
|
|
<p className="text-xs text-ink-500">
|
|
{dist.multi
|
|
? `of ${dist.base.toLocaleString()} who shared`
|
|
: `${dist.base.toLocaleString()} shared`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2.5">
|
|
{items.map((it, i) => (
|
|
<DistRow
|
|
key={it.value}
|
|
label={labelFor(field, it.value)}
|
|
pct={Math.max(1, Math.round((it.count / dist.base) * 100))}
|
|
widthPct={Math.max(4, Math.round((it.count / max) * 100))}
|
|
rank={i}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|