fix(site-finder): address PR #87 auto-review minor feedback

Backend (parcels.py):
- Centrality factor: weight=1.0 вместо weight=center_bonus (semantic — decay
  для bonus не применяется, contribution = bonus IS the value). Защищает
  future PDF-export/UI который мог бы показать weight отдельно от contribution.
- group_totals type: dict[str, dict[str, float | int]] — count это int,
  contribution и contribution_pct это float. Уточняет hint для future mypy.
- Top-3 sort convention — добавлен inline-комментарий: positives "[:3] от
  descending" (most-positive first); negatives "[-3:][::-1]" (most-negative
  first). Оба "dominant first".

Frontend (ScoreBreakdownPanel.tsx):
- Stacked bar legend orphan fix: positive groups идут в legend под баром (как
  до того); negative groups показываются отдельной строкой ниже "Снижают балл —
  Шум/трамвай: −0.46". Никаких swatch'ей без bar-сегмента.

Per auto-review on 1d1c169.
This commit is contained in:
lekss361 2026-05-12 00:54:32 +03:00
parent 1d1c169365
commit 97dc4ba081
2 changed files with 45 additions and 5 deletions

View file

@ -612,7 +612,9 @@ def analyze_parcel(
else:
center_bonus = 0.0
# X1 (#47): centrality как отдельный synthetic factor в breakdown
# X1 (#47): centrality как отдельный synthetic factor в breakdown.
# NB: для centrality decay не применяется (bonus IS the value), поэтому
# weight=1.0 семантически — "no decay multiplier"; contribution = center_bonus.
if center_bonus > 0:
factors_detailed.append(
{
@ -621,7 +623,7 @@ def analyze_parcel(
"category_ru": "Центральность",
"group": "Локация",
"value": round(dist_to_center_km, 2),
"weight": center_bonus,
"weight": 1.0,
"contribution": round(center_bonus, 2),
"verbal": (
f"Близость к центру ЕКБ ({dist_to_center_km:.1f}км) — "
@ -1004,11 +1006,15 @@ def analyze_parcel(
f["contribution_pct"] = round(100.0 * abs(f["contribution"]) / abs_total, 1)
factors_sorted = sorted(factors_detailed, key=lambda x: x["contribution"], reverse=True)
# Convention: оба top-list'а отсортированы "dominant first":
# positives → most-positive first (просто [:3] от descending sort)
# negatives → most-negative first ([-3:][::-1] = последние три (наиболее
# отрицательные) реверсом, чтобы first item был самым большим минусом)
score_top_3_positives = [f for f in factors_sorted if f["contribution"] > 0][:3]
score_top_3_negatives = [f for f in factors_sorted if f["contribution"] < 0][-3:][::-1]
# By-group totals — для stacked-bar в UI
group_totals: dict[str, dict[str, float]] = {}
# By-group totals — для stacked-bar в UI. count это int, contribution* — float.
group_totals: dict[str, dict[str, float | int]] = {}
for f in factors_detailed:
g = group_totals.setdefault(
f["group"], {"contribution": 0.0, "count": 0, "contribution_pct": 0.0}

View file

@ -111,7 +111,8 @@ export function ScoreBreakdownPanel({
color: "#6b7280",
}}
>
{byGroup.map((g) => (
{/* Positive groups — visible в баре */}
{positiveGroups.map((g) => (
<div
key={g.group}
style={{ display: "flex", alignItems: "center", gap: 4 }}
@ -134,6 +135,39 @@ export function ScoreBreakdownPanel({
</div>
))}
</div>
{/* Negative groups отдельной "drag" линией под баром (legend для bar
использует только positive, чтобы не было orphan swatches без сегмента) */}
{byGroup
.filter((g) => g.contribution < 0)
.map((g) => (
<div
key={`neg-${g.group}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
marginTop: 6,
fontSize: 12,
color: "#6b7280",
}}
>
<span
style={{
display: "inline-block",
width: 10,
height: 10,
borderRadius: 2,
background: GROUP_COLORS[g.group] ?? GROUP_COLORS.Прочее,
}}
/>
<span>
Снижают балл {g.group}:{" "}
<strong style={{ color: "#dc2626" }}>
{fmtContribution(g.contribution)}
</strong>
</span>
</div>
))}
</div>
)}