File size: 5,517 Bytes
9d2d895 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | /**
* Pure builders + data helpers for the Route Explorer modal.
*
* Kept in a sibling -utils file so node:test can import it without pulling
* in the @/services/i18n dependency chain (per
* `feedback_panel_utils_split_for_node_test.md`).
*/
import COUNTRY_PORT_CLUSTERS from '../../../scripts/shared/country-port-clusters.json';
import { toFlagEmoji } from '../../utils/country-flag';
// βββ Country list βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const regionNames = (() => {
try {
return new Intl.DisplayNames(['en'], { type: 'region' });
} catch {
return null;
}
})();
export interface CountryListEntry {
iso2: string;
name: string;
flag: string;
searchKey: string; // lowercase, no diacritics, used for typeahead matching
}
function isIso2Key(key: string): boolean {
return /^[A-Z]{2}$/.test(key);
}
function normalizeForSearch(s: string): string {
return s
.toLowerCase()
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9 ]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
let cachedCountries: CountryListEntry[] | null = null;
/**
* Get all 197 port-clustered countries with display names + flags. Cached.
* Sorted alphabetically by display name.
*/
export function getAllCountries(): CountryListEntry[] {
if (cachedCountries) return cachedCountries;
const out: CountryListEntry[] = [];
for (const key of Object.keys(COUNTRY_PORT_CLUSTERS as Record<string, unknown>)) {
if (!isIso2Key(key)) continue;
const name = regionNames?.of(key) ?? key;
out.push({
iso2: key,
name,
flag: toFlagEmoji(key),
searchKey: `${normalizeForSearch(name)} ${key.toLowerCase()}`,
});
}
out.sort((a, b) => a.name.localeCompare(b.name));
cachedCountries = out;
return out;
}
/**
* Filter the country list by a typeahead query. Empty query returns the
* full list. Matches against display name + ISO2.
*/
export function filterCountries(
query: string,
list: CountryListEntry[] = getAllCountries(),
): CountryListEntry[] {
const q = normalizeForSearch(query);
if (!q) return list;
return list.filter((c) => c.searchKey.includes(q));
}
// βββ HS2 list βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export interface Hs2Entry {
hs2: string; // numeric, may be 1 or 2 chars
label: string;
searchKey: string;
}
/**
* The HS2 sectors the Route Explorer surfaces. Kept in sync with the
* server-side `HS2_LABELS` table in get-sector-dependency.ts so users only
* see codes the backend can actually compute against.
*/
const HS2_LABELS: ReadonlyArray<readonly [string, string]> = [
['1', 'Live Animals'],
['2', 'Meat'],
['3', 'Fish & Seafood'],
['4', 'Dairy'],
['6', 'Plants & Flowers'],
['7', 'Vegetables'],
['8', 'Fruit & Nuts'],
['10', 'Cereals'],
['11', 'Milling Products'],
['12', 'Oilseeds'],
['15', 'Animal & Vegetable Fats'],
['16', 'Meat Preparations'],
['17', 'Sugar'],
['18', 'Cocoa'],
['19', 'Food Preparations'],
['22', 'Beverages & Spirits'],
['23', 'Residues & Animal Feed'],
['24', 'Tobacco'],
['25', 'Salt & Cement'],
['26', 'Ores, Slag & Ash'],
['27', 'Mineral Fuels & Energy'],
['28', 'Inorganic Chemicals'],
['29', 'Organic Chemicals'],
['30', 'Pharmaceuticals'],
['31', 'Fertilizers'],
['38', 'Chemical Products'],
['39', 'Plastics'],
['40', 'Rubber'],
['44', 'Wood'],
['47', 'Pulp & Paper'],
['48', 'Paper & Paperboard'],
['52', 'Cotton'],
['61', 'Clothing (Knitted)'],
['62', 'Clothing (Woven)'],
['71', 'Precious Metals & Gems'],
['72', 'Iron & Steel'],
['73', 'Iron & Steel Articles'],
['74', 'Copper'],
['76', 'Aluminium'],
['79', 'Zinc'],
['80', 'Tin'],
['84', 'Machinery & Mechanical Appliances'],
['85', 'Electrical & Electronic Equipment'],
['86', 'Railway'],
['87', 'Vehicles'],
['88', 'Aircraft'],
['89', 'Ships & Boats'],
['90', 'Optical & Medical Instruments'],
['93', 'Arms & Ammunition'],
];
let cachedHs2: Hs2Entry[] | null = null;
export function getAllHs2(): Hs2Entry[] {
if (cachedHs2) return cachedHs2;
cachedHs2 = HS2_LABELS.map(([hs2, label]) => ({
hs2,
label,
searchKey: `${normalizeForSearch(label)} hs${hs2} ${hs2}`,
}));
return cachedHs2;
}
export function filterHs2(query: string, list: Hs2Entry[] = getAllHs2()): Hs2Entry[] {
const q = normalizeForSearch(query);
if (!q) return list;
return list.filter((e) => e.searchKey.includes(q));
}
// βββ Cargo type inference ββββββββββββββββββββββββββββββββββββββββββββββββββ
export type ExplorerCargo = 'container' | 'tanker' | 'bulk' | 'roro';
/**
* Auto-infer a cargo type from the selected HS2 chapter. Returns 'container'
* as a sensible default for codes not in the explicit map.
*/
export function inferCargoFromHs2(hs2: string | null): ExplorerCargo {
if (!hs2) return 'container';
const code = hs2.replace(/\D/g, '');
if (code === '27') return 'tanker';
if (['10', '11', '12', '15', '26'].includes(code)) return 'bulk';
if (['87', '89'].includes(code)) return 'roro';
// Container default covers 84/85/90/61/62/etc.
return 'container';
}
|