spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1/**2 * Helpers for the enrichment profile (Wikidata / Wikipedia / homepage / registries): source labels, description3 * attribution, socials, corporate-structure grouping, leadership merge. Server- and client-safe (no React).4 */5import type { CompanyCard, CompanyProfile, CompanySocials, Person, ProfileSource, Relationship } from './types';67/** Human label for a provenance source id (`wikidata`, `sec_edgar`, `page`, …). Unknown ids are humanised, never hidden. */8export const SOURCE_LABELS: Record<string, string> = {9 wikidata: 'Wikidata',10 wikipedia: 'Wikipedia',11 homepage: 'Website',12 website: 'Website',13 page: 'Leadership page',14 leadership_page: 'Leadership page',15 llm: 'LLM',16 sec: 'SEC EDGAR',17 sec_edgar: 'SEC EDGAR',18 edgar: 'SEC EDGAR',19 gleif: 'GLEIF',20 registry: 'Registry',21 opencorporates: 'OpenCorporates',22 crunchbase: 'Crunchbase',23 observed: 'Observed',24};25export function sourceLabel(source: string | null | undefined): string {26 if (!source) return 'Unknown source';27 const k = source.toLowerCase();28 if (SOURCE_LABELS[k]) return SOURCE_LABELS[k];29 const w = k.replace(/[_-]+/g, ' ').trim();30 return w.charAt(0).toUpperCase() + w.slice(1);31}3233/** Description + attribution: profile first, then the registry description (unattributed → no attribution line). */34export function descriptionOf(c: CompanyCard): { text: string; source: CompanyProfile['description_source'] | null; url: string | null; license: string | null } | null {35 const p = c.profile;36 if (p?.description) return { text: p.description, source: p.description_source, url: p.description_url, license: p.description_license };37 if (c.description) return { text: c.description, source: null, url: null, license: null };38 return null;39}4041/** Best available logo candidates, in order (profile logo → profile icon → legacy `logo_url`). */42export function logoCandidates(c: { logo_url?: string | null; profile?: CompanyProfile | null }): string[] {43 const out: string[] = [];44 for (const u of [c.profile?.logo_url, c.profile?.icon_url, c.logo_url]) if (u && !out.includes(u)) out.push(u);45 return out;46}4748/** 1–2 letter monogram for the fallback plate ("Dassault Systèmes" → "DS", "Arm" → "A"). */49export function monogram(name: string): string {50 const words = name51 .replace(/[^\p{L}\p{N} ]+/gu, ' ')52 .split(/\s+/)53 .filter(Boolean);54 if (!words.length) return '?';55 if (words.length === 1) return words[0]!.slice(0, 1).toUpperCase();56 return `${words[0]!.charAt(0)}${words[1]!.charAt(0)}`.toUpperCase();57}5859export const SOCIAL_LABELS: Record<keyof CompanySocials, string> = { linkedin: 'LinkedIn', x: 'X', youtube: 'YouTube', facebook: 'Facebook', instagram: 'Instagram', github: 'GitHub', tiktok: 'TikTok', crunchbase: 'Crunchbase' };60const SOCIAL_ORDER: (keyof CompanySocials)[] = ['linkedin', 'x', 'youtube', 'github', 'instagram', 'facebook', 'tiktok', 'crunchbase'];61export function socialsOf(p: CompanyProfile | null | undefined): { key: keyof CompanySocials; label: string; url: string }[] {62 if (!p?.socials) return [];63 return SOCIAL_ORDER.filter((k) => typeof p.socials[k] === 'string' && /^https?:\/\//i.test(p.socials[k] as string)).map((k) => ({ key: k, label: SOCIAL_LABELS[k], url: p.socials[k] as string }));64}6566/** Per-field provenance lookup (`sources[]` is a flat list keyed by `field`). */67export function sourceFor(p: CompanyProfile | null | undefined, ...fields: string[]): ProfileSource | null {68 if (!p?.sources?.length) return null;69 for (const f of fields) {70 const s = p.sources.find((x) => x.field === f);71 if (s) return s;72 }73 return null;74}7576/** Wikidata-sourced people first by role weight (CEO → chair → founders → other executives), then page-observed, then name. */77const ROLE_RANK: [RegExp, number][] = [78 [/\b(chief executive|ceo|managing director|président-directeur|directeur général)\b/i, 0],79 [/\b(executive chair|chair(man|woman|person)?|chair of the board|président du conseil)\b/i, 1],80 [/\b(co-?founder|founder|fondateur|fondatrice)\b/i, 2],81 [/\b(president|chief \w+ officer|c[a-z]o\b|general counsel)\b/i, 3],82 [/\b(vp|vice president|head of|director)\b/i, 4],83];84export function roleRank(title: string | null | undefined): number {85 if (!title) return 6;86 for (const [re, r] of ROLE_RANK) if (re.test(title)) return r;87 return 5;88}8990export type MergedPerson = Person & { sources: ('wikidata' | 'page')[] };91/** Merge page-observed and Wikidata-sourced people by normalised name; page metadata wins, both source chips are kept. */92export function mergePeople(list: Person[]): MergedPerson[] {93 const byKey = new Map<string, MergedPerson>();94 for (const p of list) {95 const src = p.source === 'wikidata' ? 'wikidata' : 'page';96 const key = p.name97 .normalize('NFD')98 .replace(/[\u0300-\u036f]/g, '')99 .toLowerCase()100 .replace(/[^a-z0-9]+/g, ' ')101 .trim();102 const cur = byKey.get(key);103 if (!cur) {104 byKey.set(key, { ...p, sources: [src] });105 continue;106 }107 if (!cur.sources.includes(src)) cur.sources.push(src);108 // prefer the page row's identity/title (observed first-party), keep the richer title otherwise109 if (src === 'page' && cur.source === 'wikidata') Object.assign(cur, { ...p, sources: cur.sources });110 else if (!cur.title && p.title) cur.title = p.title;111 cur.is_executive = cur.is_executive || p.is_executive;112 }113 return [...byKey.values()].sort((a, b) => {114 const ra = roleRank(a.title);115 const rb = roleRank(b.title);116 if (ra !== rb) return ra - rb;117 if (a.is_executive !== b.is_executive) return a.is_executive ? -1 : 1;118 return a.name.localeCompare(b.name);119 });120}121122/** Corporate-structure groups, in display order. `PARENT_OF X` means X is a subsidiary of this company. */123export const STRUCTURE_GROUPS: { id: string; kinds: string[]; label: string; hint: string }[] = [124 { id: 'parent', kinds: ['SUBSIDIARY_OF', 'PARENT'], label: 'Parent', hint: 'This company is recorded as a subsidiary of' },125 { id: 'owners', kinds: ['OWNED_BY'], label: 'Owners', hint: 'Recorded owners / significant shareholders' },126 { id: 'acquired_by', kinds: ['ACQUIRED_BY'], label: 'Acquired by', hint: 'Recorded acquirer' },127 { id: 'subsidiaries', kinds: ['PARENT_OF', 'SUBSIDIARY'], label: 'Subsidiaries', hint: 'Companies recorded as subsidiaries' },128 { id: 'owns', kinds: ['OWNER_OF'], label: 'Owns', hint: 'Recorded holdings' },129 { id: 'acquisitions', kinds: ['ACQUIRED', 'ACQUIRER_OF'], label: 'Acquisitions', hint: 'Companies recorded as acquired' },130];131export function groupRelationships(rels: Relationship[]): { group: (typeof STRUCTURE_GROUPS)[number] | { id: 'other'; label: string; hint: string; kinds: string[] }; rows: Relationship[] }[] {132 const used = new Set<Relationship>();133 const out: { group: (typeof STRUCTURE_GROUPS)[number] | { id: 'other'; label: string; hint: string; kinds: string[] }; rows: Relationship[] }[] = [];134 for (const g of STRUCTURE_GROUPS) {135 const rows = rels.filter((r) => g.kinds.includes((r.kind ?? '').toUpperCase()));136 if (rows.length) {137 rows.forEach((r) => used.add(r));138 out.push({ group: g, rows: sortRel(rows) });139 }140 }141 const other = rels.filter((r) => !used.has(r));142 if (other.length) out.push({ group: { id: 'other', label: 'Other relationships', hint: 'Competitors, partners and other recorded links', kinds: [] }, rows: sortRel(other) });143 return out;144}145function sortRel(rows: Relationship[]): Relationship[] {146 // current (no valid_to) first, then most recent, then confidence147 return [...rows].sort((a, b) => {148 const ea = a.valid_to ? 1 : 0;149 const eb = b.valid_to ? 1 : 0;150 if (ea !== eb) return ea - eb;151 if ((a.valid_from ?? '') !== (b.valid_from ?? '')) return (b.valid_from ?? '').localeCompare(a.valid_from ?? '');152 return b.confidence - a.confidence;153 });154}155export function relationshipName(r: Relationship): string {156 return r.company?.display_name ?? r.to_name ?? 'Unnamed counterpart';157}158