Skip to content

feat(web): map region Information Card on hover#141

Open
StanislavBG wants to merge 6 commits into
midt-bg:mainfrom
StanislavBG:feat/map-info-card
Open

feat(web): map region Information Card on hover#141
StanislavBG wants to merge 6 commits into
midt-bg:mainfrom
StanislavBG:feat/map-info-card

Conversation

@StanislavBG

Copy link
Copy Markdown
Contributor

What

On /map, hovering a region now fills an Information Card beside the map (not an overlay) with that zone's summary stats:

  • Стойност (value), Дял от всички области (share of all regions), Договори (contracts), Институции (institutions), and the region's NUTS2 район.

These are the same figures the ranked table below already lists — surfaced on hover, on the side, so they don't obscure the map.

Progressive enhancement (no chart JS in the baseline)

  • With JS: hovering a region updates the card (client state); the focused region gets an accent outline.
  • No JS / crawlers: the native per-region <title> tooltip still works and the card shows a prompt.
  • The card only renders when a total is passed, so the compact map preview on /analytics is unchanged.
  • Accessibility: aria-live on the card; the ranked table remains the primary keyboard/AT data path.

Files

  • apps/web/app/components/Choropleth.tsx — hover state + side card (optional total prop).
  • apps/web/app/routes/map.tsx — passes total.
  • apps/web/app/app.css.map-layout / .map-card styles + region hover highlight.

Checks

  • pnpm --filter @sigma/web typecheck → clean; the 3 changed files are Prettier-clean. Live queries only; no data changes.

@lyubomir-bozhinov lyubomir-bozhinov added enhancement Нова функционалност или предложение web Област: web labels Jun 26, 2026

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

И тук — чиста работа, Станислав.

Прогресивното подобрение е направено правилно: без JS остава <title> tooltip-ът, картата е страничен панел (не overlay), aria-live е на място, а withCard = total != null пази съществуващите callers (analytics-hub) необвързани. Браво и за това, че „Институции" е заключено само за oblast режим — MacroRegionSpend няма authorities, така че това е съзнателно, не случайно. Проверих с red-team тест, че знаменателят на „Дял" е консистентен в двата режима (sum(macroRegions) === sum(regions)), включително при NULL / непознати области — сметката е вярна.

Две неща:

  1. Етикетът „Дял от всички области" в режим „район" (ниско, но точност). Етикетът е статичен и в двата режима; в режим „район" числителят е район (NUTS2), така че „области" е неточно — числото е вярно (същият знаменател), но съществителното не пасва. На министерски видим инструмент за прозрачност думата има значение. Предложение: режим-зависим етикет, или неутрално „Дял от разпределените разходи".

  2. Тестове (препоръчително) за сметката на дела, tierForShape и mapping-а oblast ↔ район.

Mergeable, без блокери. 👍

StanislavBG added a commit to StanislavBG/sigma-pr that referenced this pull request Jun 26, 2026
Addresses review on midt-bg#141:
- The card share label is now mode-aware (shareLabel): 'Дял от всички области'
  in oblast mode, 'Дял от всички райони' in район mode — the noun matches the
  numerator (denominator is unchanged; районите partition the same области).
- Extract the choropleth pure logic (tierer, shareOfTotal, shareLabel,
  tierForShape, isActiveShape) into apps/web/app/lib/choropleth.ts and add
  choropleth.test.ts (share calc + zero/null guard, tier quantiles, the
  oblast↔район mapping per mode, active-shape highlighting). Follows the repo's
  pure-logic lib/*.test.ts convention — no render tests introduced. Component
  behaviour unchanged.
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Благодаря! 🙏 Адресирах двете точки в 219a7cc.

1. Етикетът „Дял" в режим „район". Направих го режим-зависим вместо неутрален: Дял от всички области в режим области и Дял от всички райони в режим райони (капсулирано в тествана shareLabel(group)). Така съществителното съвпада точно с числителя (oblast = NUTS3, район = NUTS2). Знаменателят е същият в двата режима (total = сумата по 28-те области = сумата по 6-те района, защото районите ги разделят), затова само думата се сменя. Избрах режим-зависимия вариант пред неутралния, защото картата вече се разклонява по group (под-редът „Район:", редът „Институции" само за области) и таблиците отдолу вече надписват NUTS2 редовете „Район" — така етикетът е по-точен при нулева допълнителна сложност.

2. Тестове. Изнесох чистата логика на картата в apps/web/app/lib/choropleth.ts (tierer, shareOfTotal, shareLabel, tierForShape, isActiveShape) и добавих choropleth.test.ts (14 теста): сметката на дела + guard за нулев/липсващ знаменател (0, не NaN/∞); квантилните tier-ове (вкл. че максимумът пада в tier 4 при малко N — само стойности строго над праг качват по-нагоре); mapping-а oblast↔район в tierForShape (в режим район съседните области от един район получават еднакъв tier); и highlight-а isActiveShape. Следва конвенцията на проекта за чиста логика в lib/*.test.ts (в репото няма render-тестове на компоненти, затова не въвеждам нови). Поведението на компонента е непроменено.

(Бележка: NULL/непознати области се обработват на data слоя — попадат в „без посочена област" преди да станат RegionSpend[] — и вече са покрити в packages/db/src/queries/regions.test.ts.)

@lyubomir-bozhinov lyubomir-bozhinov left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Благодаря, Станислав — проверих наново срещу текущия head (219a7cc).

И двете предишни бележки са затворени:

  • shareLabel вече ползва правилните родителни форми („Дял от всички райони" / „Дял от всички области") за двата режима.
  • shareOfTotal е защитена срещу деление на нула / NaN (total && total > 0 ? value / total : 0).

Логиката на tierer е коректна — проследих я за реалния случай (6 района с положителна стойност, N=6): праговете дават разпределение [1,1,2,3,4,5], т.е. най-тъмният нюанс (tier 5) се пада на водещия район. Тестът t([0,100,...,500]) демонстрира тавана при N=5 (нулата отпада след филтъра v > 0), което си е по-друг случай — коментарът в кода е точен.

Approve от мен.

StanislavBG added a commit to StanislavBG/sigma-pr that referenced this pull request Jun 27, 2026
Review feedback on midt-bg#141:
- SVG aria-label is now mode-dependent („по области" / „по райони") — it no
  longer asserts the wrong noun to screen readers after a toggle to район mode.
- the grouping toggle renders only after hydration (it only does anything with
  JS); no-JS users no longer see a real-looking but inert control.
- guard test pins the share denominator invariant: sum(regions) == sum(macros)
  == totalValueEur, so a future change that breaks the roll-up fails before ship.
@ydimitrof

Copy link
Copy Markdown

Verification complete. This is a frontend-only PR (React choropleth + CSS + extracted pure logic with tests). I confirmed the tierer behavior manually (N=6 → [1,1,2,3,4,5], t(500)=4, t(99999)=5 — matches the code comments and reviewer's trace exactly), the contract types support every field the card reads, the "Институции" row is correctly gated to oblast mode, and the only DB touch is an added test against an already-parameterized query. No injection surface, no secrets, no dependency or URL changes.


Благодаря за тази чиста и добре обоснована промяна, Станислав. 🙏 Прегледах я стриктно — код, тестове, всички коментари по PR-а, и с фокус върху сигурност и цялост на данните.

Сигурност (OWASP) — чисто. Промяната е изцяло на клиента (hover състояние + страничен панел + CSS) и не въвежда никаква нова повърхност за атака:

  • Няма SQL: единствената промяна в packages/db е добавен тест; заявката в regions.ts не е пипана и остава параметризирана (bind параметри, без стрингова интерполация).
  • Няма XSS: цялото съдържание на картата минава през стандартното екраниране на React (<title>{label}, {active.name} — текстови възли, не dangerouslySetInnerHTML). Геометрията (d={shape.d}) е комитнат асет, не потребителски вход.
  • Няма тайни, нови URL-и, нови зависимости или обфускиран/подозрителен код. A11y/A02/A03/A05 по OWASP Top 10 — без забележки.

Цялост на данните — потвърдена. Знаменателят на „Дял" е един и същ в двата режима (total), а новият guard-тест в regions.test.ts пиновва инварианта sum(regions) === sum(macros) === totalValueEur — така всяко бъдещо разминаване между roll-up-а и сумата ще падне преди merge. shareOfTotal е защитена срещу деление на нула/undefined (total && total > 0 ? … : 0) — 0, никога NaN/∞. Проследих tierer ръчно за реалния случай N=6: разпределението е [1,1,2,3,4,5] (водещият район → най-тъмния tier), а таванът при малко N (t(max) пада в tier 4) е коректно тестван и документиран.

Съответствие с описанието на PR-а. Реализацията отговаря на заявеното: странична Information Card (не overlay), прогресивно подобрение (<title> fallback без JS, toggle-ът се рендира само след хидратация), режим-зависими aria-label/shareLabel, и withCard = total != null пази analytics-hub preview-а непроменен. Двете предишни бележки на @lyubomir-bozhinov (режим-зависим етикет + тестове) са адресирани и потвърдени.

Малка, необвързваща бележка: aria-live="polite" се задейства при всяко местене на мишката, което може да е словоохотливо за екранни четци — но таблицата отдолу остава основният keyboard/AT път, така че е приемлив компромис. Не е блокер.

Забележка по проверката: не пуснах пълния vitest сют локално (worktree-ът е без инсталирани зависимости — нужен е pnpm install), затова верифицирах чистата логика ръчно; предишен запис потвърждава, че тестовете на #141 минават. Препоръчвам да се разчита на CI за зеления сют.

Вердикт: Approve — без блокери. Промяната е сигурна, OWASP-съвместима, с коректна цялост на данните и покрита с тестове.

Hovering a region on the /map choropleth now fills an Information Card beside the
map (not an overlay) with that region's summary stats — value, share of all
regions, contracts, institutions, and its NUTS2 район. Progressive enhancement:
no-JS keeps the native <title> tooltip and the card shows a prompt. The compact
analytics-hub map preview is unchanged (the card only renders when a total is passed).
Adds a toggle in the map Information Card to switch the choropleth between oblasts
and planning regions (each район is a group of oblasts). In район mode each oblast
is coloured by its parent район's total, hovering highlights the whole район, and
the card shows the район's value/share/contracts. Client-side (data already loaded);
the toggle lives in the card so all map controls stay together.
Addresses review on midt-bg#141:
- The card share label is now mode-aware (shareLabel): 'Дял от всички области'
  in oblast mode, 'Дял от всички райони' in район mode — the noun matches the
  numerator (denominator is unchanged; районите partition the same области).
- Extract the choropleth pure logic (tierer, shareOfTotal, shareLabel,
  tierForShape, isActiveShape) into apps/web/app/lib/choropleth.ts and add
  choropleth.test.ts (share calc + zero/null guard, tier quantiles, the
  oblast↔район mapping per mode, active-shape highlighting). Follows the repo's
  pure-logic lib/*.test.ts convention — no render tests introduced. Component
  behaviour unchanged.
Review feedback on midt-bg#141:
- SVG aria-label is now mode-dependent („по области" / „по райони") — it no
  longer asserts the wrong noun to screen readers after a toggle to район mode.
- the grouping toggle renders only after hydration (it only does anything with
  JS); no-JS users no longer see a real-looking but inert control.
- guard test pins the share denominator invariant: sum(regions) == sum(macros)
  == totalValueEur, so a future change that breaks the roll-up fails before ship.
@StanislavBG StanislavBG force-pushed the feat/map-info-card branch from 4d3323d to 35cd784 Compare July 3, 2026 15:13
@StanislavBG

Copy link
Copy Markdown
Contributor Author

Rebase-нат върху новия main (linear, без merge commits): CSS-ът е преместен от app.css в styles/pages.css при choropleth блока; prettier-чист, lint/typecheck (7/7) и тестовете (web 349, db 184) минават.
@todorkolev готов за approve/merge.

@ydimitrof

Copy link
Copy Markdown

Verification complete. Frontend-only PR (React choropleth + грид/hover CSS + extracted pure logic with tests). Confirmed post-rebase: .bind(...params) with ? placeholders and a static whitelisted WHERE in regions.ts (untouched by this PR — only a test added), MacroRegionSpend/RegionSpend contract fields all exist, total = data.totalValueEur, and the tierer trace matches the code comments. No injection surface, no secrets, no new deps/URLs.


Благодаря за тази чиста и добре обоснована промяна, Станислав. 🙏 Прегледах я стриктно срещу текущия head след rebase-а — код, тестове и всички коментари по PR-а, с фокус върху сигурност и цялост на данните.

Сигурност (OWASP Top 10) — чисто. Промяната е изцяло на клиента (hover състояние + страничен панел + toggle за групиране + CSS) и не отваря нова повърхност за атака:

  • Без SQL (A03). Единствената промяна в packages/db е добавен тест. Заявката в regions.ts не е пипана: WHERE се сглобява само от статични фрагменти с ? placeholder-и, всички стойности (sector, year, funding) минават през .bind(...params) — няма стрингова интерполация на потребителски вход.
  • Без XSS (A03). Цялото съдържание на картата минава през стандартното екраниране на React (<title>{label}, {active.name}, {money(...)}/{pct(...)}/{count(...)} — текстови възли, без dangerouslySetInnerHTML). Геометрията (d={shape.d}) е комитнат асет, не вход.
  • Без тайни, нови URL-и, нови зависимости или обфускиран/подозрителен код (A02/A05/A08 — без забележки).

Цялост на данните — потвърдена.

  • shareOfTotal е защитена срещу деление на нула/undefined (total && total > 0 ? value / total : 0) → 0, никога NaN/∞; покрито с тест.
  • Знаменателят на „Дял" е един и същ в двата режима (total), а новият guard-тест в regions.test.ts пиновва инварианта sum(regions) === sum(macros) === totalValueEur — така всяко бъдещо разминаване между roll-up-а по райони и сумата по области ще падне преди merge.
  • Проследих tierer ръчно за реалния случай N=6: разпределение [1,1,2,3,4,5] (водещият район → най-тъмния tier); таванът при малко N (t(max) в tier 4) е коректно тестван и документиран в кода.
  • tierForShape/isActiveShape са тествани за oblast↔район mapping-а, за orphan/undefined вход (→ tier 0) и за highlight-а на целия район.

Съответствие с описанието на PR-а. Реализацията отговаря на заявеното: странична Information Card (не overlay), режим-зависими aria-label/shareLabel, прогресивно подобрение (<title> fallback без JS, toggle-ът се рендира само след хидратация, за да не се показва инертен контрол на no-JS потребители), редът „Институции" коректно заключен за режим „области" (MacroRegionSpend няма authorities), и withCard = total != null пази analytics-hub preview-а непроменен. Двете предишни бележки на @lyubomir-bozhinov (режим-зависим етикет + тестове) са адресирани и потвърдени с approve. Rebase-ът е чист — CSS-ът е преместен в styles/pages.css без промяна на поведението.

Малка, необвързваща бележка: aria-live="polite" се задейства при всяко местене на мишката, което може да е словоохотливо за екранни четци — но таблицата отдолу остава основният keyboard/AT път, така че компромисът е приемлив. Не е блокер.

Забележка по проверката: не пуснах пълния vitest сют локално (worktree-ът е без pnpm install), затова верифицирах чистата логика и заявката ръчно; предишен запис потвърждава зелен сют на #141. Препоръчвам да се разчита на CI за финалния зелен статус.

Вердикт: Approve — без блокери. Промяната е сигурна, OWASP-съвместима, с коректна цялост на данните и покрита с тестове.

@StanislavBG

Copy link
Copy Markdown
Contributor Author

@todorkolev готов за ревю 🙏 — rebase-нат на main, CI зелен, prettier-чист, CSS промените в styles/* (app.css само @import). Резолвнати нишки. Approve-ни когато ти е удобно.

@todorkolev

Copy link
Copy Markdown
Collaborator

Като цяло всичко трябва да работи добре и на мобилни устройства. В този случай hover не е добра интеракция - на телефон/таблет няма курсор, който да посочи, така че информационната карта остава недостъпна на докосване.

Добре е детайлите да могат да се виждат и на click/tap, за да работи картата и без hover.

onMouseEnter never fires on touch, so the region Information Card on /map stayed stuck on its
placeholder hint for every mobile/tablet visitor. Add onClick alongside onMouseEnter so tapping a
region populates the card the same way hovering does on desktop.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Одобрявам. Ре-проверих на 35cd784: accuracy чисто (regions.ts непокътнат; shareOfTotal гардира total > 0, tierer филтрира v > 0, без дивизия), cache-key чисто (map.tsx чете sector/year/funding — и трите в allow-list).

Mobile: layout-ът е коректен — .map-layout е flex-wrap, .map-wrap min-width:0, SVG width:100% scroll-contained → без overflow на 320px. Една бележка (не блокер): Information Card-ът е само hover (Choropleth.tsx:87 onMouseEnter; region path-овете нямат onClick/onFocus) → touch/keyboard потребители не го попълват — точно флагманската интеракция на PR-а. Смекчено от таблиците със същите числа + native <title>. За mobile-first: tap на регион да пълни картата. Toggle бутоните са ~28px (<44px).

…t-bg#141)

По области/По райони buttons were ~28px tall on touch — below the 44px floor already used for
.pa-seg/.ov-seg/.q-grains elsewhere. Match that convention for coarse pointers.
@lyubomir-bozhinov

Copy link
Copy Markdown
Collaborator

Проверих 427d414: и двете mobile бележки затворени — регионът вече реагира на tap (onClick, Choropleth.tsx:91), а toggle-ите пораснаха до ~44px на coarse pointer (padding: 15px 10px). Approve остава.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Нова функционалност или предложение web Област: web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants