fix(site-finder): normalize POI weighted score to 0..100 on backend (#1486) #1674
5 changed files with 182 additions and 57 deletions
|
|
@ -44,6 +44,21 @@ CATEGORY_WEIGHTS: dict[str, float] = {
|
||||||
"default": 1.0,
|
"default": 1.0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Нормировочные константы (для вывода score_contribution / poi_weighted_score в 0..100) ──
|
||||||
|
#
|
||||||
|
# Теоретический максимум суммы весов top-7 POI при идеальном расположении:
|
||||||
|
# straight-line: w_i = cat_weight_i / (0+100) → max_sum = Σ(top7 cat_weights) / 100
|
||||||
|
# routing-decay: w_i = cat_weight_i * decay(0) = cat_weight_i → max_sum = Σ(top7 cat_weights)
|
||||||
|
#
|
||||||
|
# Top-7 категорий по убыванию веса: 6.0+5.0+4.5+4.5+4.0+4.0+3.5 = 31.5
|
||||||
|
_TOP7_WEIGHT_SUM: float = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
||||||
|
|
||||||
|
# straight-line mode: каждый POI берётся с множителем 1/(d+100); при d=0 → /100
|
||||||
|
_MAX_STRAIGHT_SCORE: float = _TOP7_WEIGHT_SUM / 100.0 # ≈ 0.315
|
||||||
|
|
||||||
|
# routing-decay mode: decay ∈ [0,1], при t=0 decay=1.0 → max = cat_weight
|
||||||
|
_MAX_ROUTING_SCORE: float = _TOP7_WEIGHT_SUM # = 31.5
|
||||||
|
|
||||||
|
|
||||||
class PoiScoreItem(BaseModel):
|
class PoiScoreItem(BaseModel):
|
||||||
"""Один POI в ranked-ответе."""
|
"""Один POI в ranked-ответе."""
|
||||||
|
|
@ -52,12 +67,17 @@ class PoiScoreItem(BaseModel):
|
||||||
category: str
|
category: str
|
||||||
distance_m: float
|
distance_m: float
|
||||||
weight: float
|
weight: float
|
||||||
|
# Вклад данного POI в суммарный балл, в процентах от 0..100.
|
||||||
|
# Позволяет фронтенду рендерить «долю» без знания формулы нормировки.
|
||||||
|
score_contribution: float
|
||||||
address: str | None
|
address: str | None
|
||||||
|
|
||||||
|
|
||||||
class PoiScoreResponse(BaseModel):
|
class PoiScoreResponse(BaseModel):
|
||||||
cad_num: str
|
cad_num: str
|
||||||
radius_m: int
|
radius_m: int
|
||||||
|
# Суммарный взвешенный балл инфраструктуры, нормированный в диапазон 0..100.
|
||||||
|
poi_weighted_score: float
|
||||||
top_poi: list[PoiScoreItem]
|
top_poi: list[PoiScoreItem]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -155,6 +175,7 @@ def compute_poi_weighted_top7(
|
||||||
category=category,
|
category=category,
|
||||||
distance_m=round(distance_m, 1),
|
distance_m=round(distance_m, 1),
|
||||||
weight=round(weight, 6),
|
weight=round(weight, 6),
|
||||||
|
score_contribution=0.0, # заполним после нормировки
|
||||||
address=address,
|
address=address,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -163,9 +184,18 @@ def compute_poi_weighted_top7(
|
||||||
items.sort(key=lambda x: x.weight, reverse=True)
|
items.sort(key=lambda x: x.weight, reverse=True)
|
||||||
top_items = items[:top_n]
|
top_items = items[:top_n]
|
||||||
|
|
||||||
|
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_STRAIGHT_SCORE) * 100, клэмп [0, 100]
|
||||||
|
raw_sum = sum(i.weight for i in top_items)
|
||||||
|
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_STRAIGHT_SCORE) * 100.0), 1)
|
||||||
|
|
||||||
|
# score_contribution — доля данного POI в общем балле (0..100)
|
||||||
|
for item in top_items:
|
||||||
|
item.score_contribution = round((item.weight / _MAX_STRAIGHT_SCORE) * 100.0, 1)
|
||||||
|
|
||||||
return PoiScoreResponse(
|
return PoiScoreResponse(
|
||||||
cad_num=cad_num,
|
cad_num=cad_num,
|
||||||
radius_m=radius_m,
|
radius_m=radius_m,
|
||||||
|
poi_weighted_score=poi_weighted_score,
|
||||||
top_poi=top_items,
|
top_poi=top_items,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -303,7 +333,9 @@ def compute_poi_routing_decay(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not rows:
|
if not rows:
|
||||||
return PoiScoreResponse(cad_num=cad_num, radius_m=radius_m, top_poi=[])
|
return PoiScoreResponse(
|
||||||
|
cad_num=cad_num, radius_m=radius_m, poi_weighted_score=0.0, top_poi=[]
|
||||||
|
)
|
||||||
|
|
||||||
destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]
|
destinations = [(float(r["lon"]), float(r["lat"])) for r in rows]
|
||||||
|
|
||||||
|
|
@ -311,9 +343,7 @@ def compute_poi_routing_decay(
|
||||||
times_min: list[float | None]
|
times_min: list[float | None]
|
||||||
routing_used = False
|
routing_used = False
|
||||||
try:
|
try:
|
||||||
times_min = ors_client.matrix_durations_min(
|
times_min = ors_client.matrix_durations_min(lon, lat, destinations, profile=profile)
|
||||||
lon, lat, destinations, profile=profile
|
|
||||||
)
|
|
||||||
routing_used = True
|
routing_used = True
|
||||||
except ors_client.OrsUnavailableError as exc:
|
except ors_client.OrsUnavailableError as exc:
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
@ -355,13 +385,25 @@ def compute_poi_routing_decay(
|
||||||
category=category,
|
category=category,
|
||||||
distance_m=round(distance_m, 1),
|
distance_m=round(distance_m, 1),
|
||||||
weight=round(weight, 6),
|
weight=round(weight, 6),
|
||||||
|
score_contribution=0.0, # заполним после нормировки
|
||||||
address=address,
|
address=address,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
items.sort(key=lambda x: x.weight, reverse=True)
|
items.sort(key=lambda x: x.weight, reverse=True)
|
||||||
|
top_items = items[:top_n]
|
||||||
|
|
||||||
|
# Нормировка: poi_weighted_score = (Σ weight_i / _MAX_ROUTING_SCORE) * 100, клэмп [0, 100]
|
||||||
|
raw_sum = sum(i.weight for i in top_items)
|
||||||
|
poi_weighted_score = round(min(100.0, (raw_sum / _MAX_ROUTING_SCORE) * 100.0), 1)
|
||||||
|
|
||||||
|
# score_contribution — доля данного POI в общем балле (0..100)
|
||||||
|
for item in top_items:
|
||||||
|
item.score_contribution = round((item.weight / _MAX_ROUTING_SCORE) * 100.0, 1)
|
||||||
|
|
||||||
return PoiScoreResponse(
|
return PoiScoreResponse(
|
||||||
cad_num=cad_num,
|
cad_num=cad_num,
|
||||||
radius_m=radius_m,
|
radius_m=radius_m,
|
||||||
top_poi=items[:top_n],
|
poi_weighted_score=poi_weighted_score,
|
||||||
|
top_poi=top_items,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import pytest
|
||||||
|
|
||||||
from app.services.site_finder import ors_client, poi_score
|
from app.services.site_finder import ors_client, poi_score
|
||||||
from app.services.site_finder.poi_score import (
|
from app.services.site_finder.poi_score import (
|
||||||
|
_MAX_ROUTING_SCORE,
|
||||||
|
_MAX_STRAIGHT_SCORE,
|
||||||
CATEGORY_WEIGHTS,
|
CATEGORY_WEIGHTS,
|
||||||
PoiScoreResponse,
|
PoiScoreResponse,
|
||||||
_category_radius_min,
|
_category_radius_min,
|
||||||
|
|
@ -153,6 +155,76 @@ def test_empty_db_returns_empty_top_poi():
|
||||||
assert result.top_poi == []
|
assert result.top_poi == []
|
||||||
assert result.cad_num == "66:41:0204016:10"
|
assert result.cad_num == "66:41:0204016:10"
|
||||||
assert result.radius_m == 2000
|
assert result.radius_m == 2000
|
||||||
|
assert result.poi_weighted_score == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# ── #1486: normalization 0..100 ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_straight_score_constant():
|
||||||
|
"""_MAX_STRAIGHT_SCORE = Σ(top-7 category_weights) / 100 ≈ 0.315."""
|
||||||
|
top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
||||||
|
assert _MAX_STRAIGHT_SCORE == pytest.approx(top7_sum / 100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_routing_score_constant():
|
||||||
|
"""_MAX_ROUTING_SCORE = Σ(top-7 category_weights) = 31.5."""
|
||||||
|
top7_sum = sum(sorted(CATEGORY_WEIGHTS.values(), reverse=True)[:7])
|
||||||
|
assert _MAX_ROUTING_SCORE == pytest.approx(top7_sum)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poi_weighted_score_in_range():
|
||||||
|
"""poi_weighted_score должен быть в диапазоне 0..100 для реалистичных данных."""
|
||||||
|
rows = [
|
||||||
|
_make_row("Метро", "metro_stop", 400.0),
|
||||||
|
_make_row("Школа", "school", 500.0),
|
||||||
|
_make_row("Детсад", "kindergarten", 300.0),
|
||||||
|
]
|
||||||
|
db = _MockDb(rows)
|
||||||
|
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
|
||||||
|
assert 0.0 <= result.poi_weighted_score <= 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_contribution_in_range():
|
||||||
|
"""Каждый score_contribution должен быть в 0..100."""
|
||||||
|
rows = [
|
||||||
|
_make_row("Метро", "metro_stop", 200.0),
|
||||||
|
_make_row("Школа", "school", 800.0),
|
||||||
|
]
|
||||||
|
db = _MockDb(rows)
|
||||||
|
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
|
||||||
|
for item in result.top_poi:
|
||||||
|
assert (
|
||||||
|
0.0 <= item.score_contribution <= 100.0
|
||||||
|
), f"{item.category} score_contribution={item.score_contribution} вне 0..100"
|
||||||
|
|
||||||
|
|
||||||
|
def test_metro_at_zero_distance_scores_high():
|
||||||
|
"""Метро в 0м должно дать poi_weighted_score близко к max (≥19.0/100)."""
|
||||||
|
# metro weight = 6.0 / (0 + 100) = 0.06; normalized = 0.06 / _MAX_STRAIGHT_SCORE * 100
|
||||||
|
rows = [_make_row("Метро у дома", "metro_stop", 0.0)]
|
||||||
|
db = _MockDb(rows)
|
||||||
|
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
|
||||||
|
assert (
|
||||||
|
result.poi_weighted_score >= 19.0
|
||||||
|
), f"Метро у дома (d=0) должно давать ≥19/100, получили {result.poi_weighted_score}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_score_contribution_sum_equals_total():
|
||||||
|
"""Сумма score_contribution по top_poi должна совпадать с poi_weighted_score.
|
||||||
|
|
||||||
|
Допуск 0.5 — оба значения округлены независимо до 1 знака, накопленная
|
||||||
|
ошибка при N POI ≤ N * 0.05. Для top-7 это ≤ 0.35 → допуск 0.5 достаточен.
|
||||||
|
"""
|
||||||
|
rows = [
|
||||||
|
_make_row("Метро", "metro_stop", 300.0),
|
||||||
|
_make_row("Школа", "school", 600.0),
|
||||||
|
_make_row("Парк", "park", 200.0),
|
||||||
|
]
|
||||||
|
db = _MockDb(rows)
|
||||||
|
result = compute_poi_weighted_top7(db, "cad", 56.838, 60.605)
|
||||||
|
total_from_contributions = sum(i.score_contribution for i in result.top_poi)
|
||||||
|
assert total_from_contributions == pytest.approx(result.poi_weighted_score, abs=0.5)
|
||||||
|
|
||||||
|
|
||||||
def test_address_built_from_tags():
|
def test_address_built_from_tags():
|
||||||
|
|
@ -301,6 +373,7 @@ def test_routing_decay_empty_db(monkeypatch):
|
||||||
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
|
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
|
||||||
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
|
result = compute_poi_routing_decay(db, "cad", 56.838, 60.605)
|
||||||
assert result.top_poi == []
|
assert result.top_poi == []
|
||||||
|
assert result.poi_weighted_score == 0.0
|
||||||
|
|
||||||
|
|
||||||
def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
|
def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
|
||||||
|
|
@ -313,9 +386,7 @@ def test_routing_decay_score_spread_wider_than_straight_line(monkeypatch):
|
||||||
def total(rows, times):
|
def total(rows, times):
|
||||||
db = _MockDb(rows)
|
db = _MockDb(rows)
|
||||||
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
|
monkeypatch.setattr(ors_client, "is_configured", lambda: True)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(ors_client, "matrix_durations_min", lambda *_a, **_k: list(times))
|
||||||
ors_client, "matrix_durations_min", lambda *_a, **_k: list(times)
|
|
||||||
)
|
|
||||||
res = compute_poi_routing_decay(db, "cad", 56.8, 60.6)
|
res = compute_poi_routing_decay(db, "cad", 56.8, 60.6)
|
||||||
return sum(i.weight for i in res.top_poi)
|
return sum(i.weight for i in res.top_poi)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,14 @@ const CATEGORY_LABELS: Record<string, string> = {
|
||||||
bank: "Банк",
|
bank: "Банк",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// score_contribution is now 0..100 (normalized on backend, #1486).
|
||||||
|
// Thresholds: ≥20 → "отличная инфраструктура", ≥12 → "хорошая", ≥8 → "средняя", <8 → "слабая".
|
||||||
function weightBadgeVariant(
|
function weightBadgeVariant(
|
||||||
weight: number,
|
score_contribution: number,
|
||||||
): "success" | "info" | "neutral" | "warning" {
|
): "success" | "info" | "neutral" | "warning" {
|
||||||
if (weight >= 0.2) return "success";
|
if (score_contribution >= 20) return "success";
|
||||||
if (weight >= 0.12) return "info";
|
if (score_contribution >= 12) return "info";
|
||||||
if (weight >= 0.08) return "neutral";
|
if (score_contribution >= 8) return "neutral";
|
||||||
return "warning";
|
return "warning";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,10 +72,8 @@ interface Props {
|
||||||
// ── Component ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function PoiList2Gis({ items, totalScore }: Props) {
|
export function PoiList2Gis({ items, totalScore }: Props) {
|
||||||
// POI-weighted score is presented as «X / 100», so clamp to 0..100 defensively:
|
// poi_weighted_score is normalized to 0..100 on the backend (#1486).
|
||||||
// the adapter sums round(weight*100) per POI без нормировки и при достаточном
|
// Clamp defensively in case of floating-point edge cases.
|
||||||
// числе POI может выдать >100 → бессмысленное «137 / 100» (#1470). Корневую
|
|
||||||
// нормировку нужно чинить в site-finder-api.ts (useParcelPoiScoreQuery).
|
|
||||||
const displayScore = Math.min(100, Math.max(0, totalScore));
|
const displayScore = Math.min(100, Math.max(0, totalScore));
|
||||||
|
|
||||||
// Top-7, sorted by score_contribution desc
|
// Top-7, sorted by score_contribution desc
|
||||||
|
|
@ -215,9 +215,12 @@ export function PoiList2Gis({ items, totalScore }: Props) {
|
||||||
: `${(item.distance_m / 1000).toFixed(1)} км`}
|
: `${(item.distance_m / 1000).toFixed(1)} км`}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Weight badge */}
|
{/* Score contribution badge (normalized 0..100, #1486) */}
|
||||||
<Badge variant={weightBadgeVariant(item.weight)} size="sm">
|
<Badge
|
||||||
×{item.weight.toFixed(2)}
|
variant={weightBadgeVariant(item.score_contribution)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{item.score_contribution.toFixed(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,55 +1,63 @@
|
||||||
{
|
{
|
||||||
"cad_num": "66:41:0701045:42",
|
"cad_num": "66:41:0701045:42",
|
||||||
"poi_weighted_score": 76,
|
"radius_m": 2000,
|
||||||
|
"poi_weighted_score": 19.9,
|
||||||
"items": [
|
"items": [
|
||||||
{
|
|
||||||
"category": "metro_stop",
|
|
||||||
"name": "Площадь 1905 года",
|
|
||||||
"distance_m": 340,
|
|
||||||
"weight": 0.25,
|
|
||||||
"score_contribution": 22
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"category": "park",
|
"category": "park",
|
||||||
"name": "Сквер Попова",
|
"name": "Сквер Попова",
|
||||||
"distance_m": 150,
|
"distance_m": 150,
|
||||||
"weight": 0.1,
|
"weight": 0.014,
|
||||||
"score_contribution": 9
|
"score_contribution": 4.4,
|
||||||
|
"address": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "school",
|
"category": "metro_stop",
|
||||||
"name": "Школа № 32",
|
"name": "Площадь 1905 года",
|
||||||
"distance_m": 480,
|
"distance_m": 340,
|
||||||
"weight": 0.15,
|
"weight": 0.013636,
|
||||||
"score_contribution": 11
|
"score_contribution": 4.3,
|
||||||
|
"address": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "kindergarten",
|
"category": "kindergarten",
|
||||||
"name": "Детский сад № 111",
|
"name": "Детский сад № 111",
|
||||||
"distance_m": 260,
|
"distance_m": 260,
|
||||||
"weight": 0.12,
|
"weight": 0.0125,
|
||||||
"score_contribution": 10
|
"score_contribution": 4.0,
|
||||||
|
"address": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "shop_mall",
|
"category": "school",
|
||||||
"name": "МЕГА Екатеринбург",
|
"name": "Школа № 32",
|
||||||
"distance_m": 1200,
|
"distance_m": 480,
|
||||||
"weight": 0.1,
|
"weight": 0.008621,
|
||||||
"score_contribution": 7
|
"score_contribution": 2.7,
|
||||||
|
"address": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "hospital",
|
"category": "hospital",
|
||||||
"name": "Городская больница № 7",
|
"name": "Городская больница № 7",
|
||||||
"distance_m": 650,
|
"distance_m": 650,
|
||||||
"weight": 0.1,
|
"weight": 0.005333,
|
||||||
"score_contribution": 8
|
"score_contribution": 1.7,
|
||||||
|
"address": null
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"category": "school",
|
"category": "school",
|
||||||
"name": "Гимназия № 9",
|
"name": "Гимназия № 9",
|
||||||
"distance_m": 820,
|
"distance_m": 820,
|
||||||
"weight": 0.08,
|
"weight": 0.005435,
|
||||||
"score_contribution": 5
|
"score_contribution": 1.7,
|
||||||
|
"address": null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"category": "shop_mall",
|
||||||
|
"name": "МЕГА Екатеринбург",
|
||||||
|
"distance_m": 1200,
|
||||||
|
"weight": 0.003077,
|
||||||
|
"score_contribution": 1.0,
|
||||||
|
"address": null
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -556,18 +556,20 @@ export function useParcelAnalyzeQuery(cad: string, horizon: number = 12) {
|
||||||
// ── Hook: useParcelPoiScoreQuery (B6) ────────────────────────────────────────
|
// ── Hook: useParcelPoiScoreQuery (B6) ────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Raw shape from backend /api/v1/parcels/{cad}/poi-score.
|
* Raw shape from backend /api/v1/parcels/{cad}/poi-score (#1486).
|
||||||
* Backend returns top_poi array with address field (not score_contribution).
|
* Backend now returns poi_weighted_score (0..100) and per-POI score_contribution (0..100)
|
||||||
* We adapt to PoiScoreResponse so downstream components are stable.
|
* normalized on the server side — no client-side reconstruction needed.
|
||||||
*/
|
*/
|
||||||
interface PoiScoreRaw {
|
interface PoiScoreRaw {
|
||||||
cad_num: string;
|
cad_num: string;
|
||||||
radius_m: number;
|
radius_m: number;
|
||||||
|
poi_weighted_score: number;
|
||||||
top_poi: Array<{
|
top_poi: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
distance_m: number;
|
distance_m: number;
|
||||||
weight: number;
|
weight: number;
|
||||||
|
score_contribution: number;
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
@ -579,8 +581,8 @@ export function useParcelPoiScoreQuery(cad: string) {
|
||||||
if (MOCK_POI_SCORE) {
|
if (MOCK_POI_SCORE) {
|
||||||
return fixturePoiScore as PoiScoreResponse;
|
return fixturePoiScore as PoiScoreResponse;
|
||||||
}
|
}
|
||||||
// Backend returns {cad_num, radius_m, top_poi: [{name,category,distance_m,weight,address}]}
|
// Backend returns normalized poi_weighted_score (0..100) and score_contribution per POI.
|
||||||
// Adapt to PoiScoreResponse {cad_num, poi_weighted_score, items} for stable consumer API.
|
// Pass through directly — no client-side weight × 100 reconstruction (#1486).
|
||||||
const raw = await apiFetch<PoiScoreRaw>(
|
const raw = await apiFetch<PoiScoreRaw>(
|
||||||
`/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`,
|
`/api/v1/parcels/${encodeURIComponent(cad)}/poi-score`,
|
||||||
);
|
);
|
||||||
|
|
@ -589,14 +591,13 @@ export function useParcelPoiScoreQuery(cad: string) {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
distance_m: p.distance_m,
|
distance_m: p.distance_m,
|
||||||
weight: p.weight,
|
weight: p.weight,
|
||||||
// score_contribution not in backend response — derive from weight (0..1) × 100
|
score_contribution: p.score_contribution,
|
||||||
score_contribution: Math.round(p.weight * 100),
|
|
||||||
}));
|
}));
|
||||||
const poi_weighted_score = items.reduce(
|
return {
|
||||||
(s, p) => s + p.score_contribution,
|
cad_num: raw.cad_num,
|
||||||
0,
|
poi_weighted_score: raw.poi_weighted_score,
|
||||||
);
|
items,
|
||||||
return { cad_num: raw.cad_num, poi_weighted_score, items };
|
};
|
||||||
},
|
},
|
||||||
staleTime: 5 * 60_000,
|
staleTime: 5 * 60_000,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue