import { householdsEnabled, searchLimit } from '../demographics/experimental-search';
import { FACTOR_LABELS } from '../results/results-store';
import { DEFAULT_WEIGHTS, scoreFactors } from './scoring';
export type MethodologyEntry = {
id: string;
term: string;
method: string;
source: string;
caveat: string;
};
export const methodology = (): MethodologyEntry[] => [
{
id: 'catchment',
term: 'Catchment (trade area)',
method: 'Walking: a circle of the chosen radius (default 800 m ≈ a 10-minute walk) around the geocoded site. Driving: a drive-time isochrone, the area reachable by road within N minutes. Every metric below is measured inside this catchment.',
source: 'Geocoding + routing reachable-range (isochrone) APIs',
caveat: 'The walking catchment is a straight-line radius, not a street-network walk distance.',
},
...(householdsEnabled()
? [
{
id: 'reach',
term: 'Reach (households)',
method: `A count of individual address points (PointAddress) found inside the catchment by an address search, up to a ${searchLimit().toLocaleString('en-US')} ceiling. Used as a dwellings/households proxy.`,
source: 'Address search (point-address records inside the catchment)',
caveat: `Counts addressable points, not residents or population. It caps at ${searchLimit().toLocaleString('en-US')}, so a very dense catchment can saturate and stop differentiating nearby sites; when that happens the scorer drops it.`,
},
]
: []),
{
id: 'demand',
term: 'Demand (whitespace scan)',
method:
'For each grid cell, the number of "demand-anchor" POIs (the categories chosen as proxies for what draws the concept\'s customers, e.g. café, supermarket, university) within the walk radius of the cell\'s centre. A bring-your-own-data demand layer, when supplied, adds its own points to that count.' +
(householdsEnabled()
? ' For resident-serving concepts (daycare, pharmacy, GP), household-demand mode also blends in RESIDENTIAL density (address points per cell, 50/50 with the anchor signal).'
: '') +
' Each signal is min-max normalized across all cells.',
source:
'POI category search for the chosen anchors; an optional bring-your-own-data demand layer' +
(householdsEnabled() ? '; address search (point-address records) for residential density' : ''),
caveat:
'A proxy for nearby activity, NOT measured footfall or population. Determined by the chosen anchors.' +
(householdsEnabled()
? ` The residential sample caps at ${searchLimit().toLocaleString('en-US')} addresses per scan, so it is a coarse relative density, not a true count.`
: ''),
},
{
id: 'competition',
term: 'Competition',
method: "The number of competitor/peer POIs (the concept's own category) inside the catchment, plus the straight-line distance to the nearest one.",
source: 'POI category search inside the catchment',
caveat: 'Counts presence and spacing only, not market share, revenue, quality, or how busy each rival is.',
},
{
id: 'accessibility',
term: 'Accessibility',
method: 'The distance to the nearest parking and the count of parking POIs inside the catchment.',
source: 'Parking POI search inside the catchment',
caveat: 'Off-street / marked parking POIs only, not on-street capacity, pricing, or live availability. The site profile shows road congestion from Traffic Area Analytics, but traffic is not part of this score.',
},
{
id: 'spend-power',
term: 'Spend power (the ranking "Demand" factor)',
method: 'Local income / spending power. This is NOT available from TomTom data, so it is scored only when you supply a demographics layer (bring-your-own GeoJSON). With none loaded, the factor is skipped, never estimated.',
source: 'Optional bring-your-own demographics layer (none by default)',
caveat: 'Distinct from the whitespace "Demand" metric above (which is anchor-POI density). Without a demographics layer this factor contributes nothing.',
},
{
id: 'opportunity-score',
term: 'Opportunity score (whitespace, 0–100)',
method: '100 × (0.6 × demand + 0.4 × peer-proximity). "demand" is the normalized anchor density above; "peer-proximity" rewards being FAR from existing peers when colocation = avoid, or CLOSE when colocation = seek. Demand is weighted higher so a genuinely busy area outranks one that is merely far from rivals.',
source: 'Derived from the Demand and peer-distance signals above',
caveat: 'A relative screening score across the scanned cells, not an absolute or predictive figure.',
},
{
id: 'site-score',
term: 'Site score (ranking, 0–100)',
method:
`A weighted sum of the normalized factors ${scoreFactors()
.map((factor) => FACTOR_LABELS[factor])
.join(' / ')}, using your weights (default ${scoreFactors()
.map((factor) => DEFAULT_WEIGHTS[factor])
.join(' / ')}).` +
" Each factor is min-max normalized across ONLY the sites being compared, so scores are relative to that set. Gates exclude any site that fails a hard requirement (e.g. parking within 300 m) with a stated reason. The panel shows the glass-box breakdown (each factor's point contribution) plus a confidence flag based on how many factors had usable data.",
source: 'Derived from the factor signals above',
caveat: 'Relative screening scores within the compared set, not absolute or predictive. Factors that do not vary across the sites are dropped from the score.',
},
{
id: 'overlap',
term: 'Catchment overlap (cannibalization check)',
method: 'The geometric intersection of two catchment areas: the shared area in km² and the percentage of the smaller catchment that overlaps.',
source: 'Geometric intersection of the two catchment polygons',
caveat: 'Geographic reach overlap only, NOT a revenue, sales, or customer-loss forecast.',
},
];
export const notMeasured = (): string[] => [
householdsEnabled()
? 'Resident population or demographics (Reach is an address/dwellings proxy, not people)'
: 'Resident population, households, or demographics',
'Income / spend power (the ranking factor is scored only when a demographics layer is supplied)',
'Footfall / pedestrian volume, vehicle counts, or vehicle-type mix',
'Revenue, sales, or financial cannibalization (overlap is geographic reach, not predicted revenue loss)',
];
export const methodologyPromptBlock = (): string => {
const lines = methodology().map((e) => `- ${e.term}: ${e.method} (Source: ${e.source}. Note: ${e.caveat})`);
return (
'HOW WE MEASURE THINGS (METHODOLOGY): when the user asks how a number, score, or metric is ' +
`calculated, what it means, or where it comes from (demand, ${householdsEnabled() ? 'reach/households, ' : ''}competition, ` +
'accessibility, spend power, opportunity score, site score, catchment, overlap, confidence), ' +
'answer precisely and ONLY from this list, combined with the specific inputs you chose for ' +
'their query (the anchor/competitor categories you searched, the radius, the weights). Quote ' +
'the method and the caveat; never invent or guess a formula. If the thing they ask about is ' +
'under "Not measured", say plainly that it is not measured and why.\n' +
`${lines.join('\n')}\n` +
`Not measured: ${notMeasured().join('; ')}.`
);
};
const escapeHtml = (value: string): string => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
export const methodologyReportHtml = (): string => {
const items = methodology()
.map((e) => `<li><strong>${escapeHtml(e.term)}</strong>: ${escapeHtml(e.method)}</li>`)
.join('');
return `<h2>Methodology & data sources</h2>
<p class="muted">All findings come from TomTom location data, queried live during this analysis.</p>
<ul class="muted">${items}</ul>`;
};
export const notMeasuredReportHtml = (): string => {
const items = notMeasured()
.map((entry) => `<li>${escapeHtml(entry)}</li>`)
.join('');
return `<h2>Not measured</h2>
<div class="callout">TomTom provides no data for the following, so this report makes <strong>no claims</strong> about them:
<ul>${items}</ul>
Validate shortlisted sites on the ground before committing.
</div>`;
};