ObjectSaleChart строил ось X из месяцев ТОЛЬКО первого типа и привязывал серии позиционно. domrf_kn_sale_graph фетчится per-type независимо от DOM.РФ → типы могут нести разные наборы месяцев — позиционная привязка тогда смещает бары/ линию цены и молча клипает лишние точки. Текущие prod-данные выровнены (457/457 объектов, идентичные месяцы), поэтому live-поломки нет, но код полагался на coincidence, не invariant. Ось из union всех месяцев + маппинг значений каждого типа по месяцу через Map (null где отсутствует), как PrinzipVelocityChart. +vitest. Closes #1246
91 lines
3.2 KiB
TypeScript
91 lines
3.2 KiB
TypeScript
"use client";
|
||
|
||
import { useMemo } from "react";
|
||
|
||
import { useObjectSaleGraph } from "@/lib/analytics-api";
|
||
|
||
import { ChartShell } from "./ChartShell";
|
||
|
||
export function ObjectSaleChart({ objId }: { objId: number | string }) {
|
||
const { data, isLoading } = useObjectSaleGraph(objId);
|
||
|
||
const option = useMemo(() => {
|
||
const points = data ?? [];
|
||
if (points.length === 0) {
|
||
return {
|
||
title: {
|
||
text: "Нет данных по продажам",
|
||
left: "center",
|
||
top: "middle",
|
||
textStyle: { color: "#9ca3af", fontSize: 13, fontWeight: 400 },
|
||
},
|
||
xAxis: { show: false },
|
||
yAxis: { show: false },
|
||
};
|
||
}
|
||
// Каждый тип (apartments / parking / nonliv) приходит от DOM.RF отдельным
|
||
// запросом со СВОИМ набором месяцев — они не обязаны совпадать (parking
|
||
// может стартовать позже). Строим ось X из union всех месяцев и маппим
|
||
// значения каждого типа по месяцу через Map (а не позиционно), чтобы бары
|
||
// и линия цены не «съезжали» на чужие месяцы и точки не обрезались молча.
|
||
const byType: Record<
|
||
string,
|
||
{
|
||
realised: Map<string, number>;
|
||
price: Map<string, number | null>;
|
||
}
|
||
> = {};
|
||
for (const p of points) {
|
||
const t = p.type;
|
||
if (!byType[t]) byType[t] = { realised: new Map(), price: new Map() };
|
||
const m = (p.report_month ?? "").slice(0, 7);
|
||
byType[t].realised.set(m, p.realised ?? 0);
|
||
byType[t].price.set(m, p.price_avg);
|
||
}
|
||
const months = Array.from(
|
||
new Set(points.map((p) => (p.report_month ?? "").slice(0, 7))),
|
||
).sort();
|
||
const series = Object.entries(byType).flatMap(([type, d]) => [
|
||
{
|
||
name: `${type} · реализовано`,
|
||
type: "bar",
|
||
stack: type,
|
||
data: months.map((m) => d.realised.get(m) ?? null),
|
||
itemStyle: { color: type === "apartments" ? "#1d4ed8" : "#9333ea" },
|
||
},
|
||
{
|
||
name: `${type} · цена ₽/м²`,
|
||
type: "line",
|
||
yAxisIndex: 1,
|
||
smooth: true,
|
||
symbol: "circle",
|
||
data: months.map((m) => {
|
||
const v = d.price.get(m);
|
||
return v ? Math.round(v) : null;
|
||
}),
|
||
lineStyle: { color: type === "apartments" ? "#0a7a3a" : "#c2410c" },
|
||
itemStyle: { color: type === "apartments" ? "#0a7a3a" : "#c2410c" },
|
||
connectNulls: true,
|
||
tooltip: {
|
||
valueFormatter: (v: number | null) =>
|
||
v == null ? "—" : `${v.toLocaleString("ru")} ₽/м²`,
|
||
},
|
||
},
|
||
]);
|
||
return {
|
||
tooltip: { trigger: "axis", axisPointer: { type: "cross" } },
|
||
legend: {},
|
||
grid: { left: 56, right: 64, top: 40, bottom: 36 },
|
||
xAxis: { type: "category", data: months },
|
||
yAxis: [
|
||
{ type: "value", name: "шт", position: "left" },
|
||
{ type: "value", name: "₽/м²", position: "right" },
|
||
],
|
||
series,
|
||
};
|
||
}, [data]);
|
||
|
||
return (
|
||
<ChartShell option={option} loading={isLoading} height={300} notMerge />
|
||
);
|
||
}
|