brickwise 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/PsqftChart.tsx +74 -18
package/package.json
CHANGED
|
@@ -28,6 +28,7 @@ const IH = H - M.top - M.bottom;
|
|
|
28
28
|
const ACCENT = "#2563eb"; // comparable (same bedrooms)
|
|
29
29
|
const MUTED = "#9ca3af"; // other units
|
|
30
30
|
const REF = "#059669"; // this listing
|
|
31
|
+
const TREND = "#7c3aed"; // tendency line
|
|
31
32
|
|
|
32
33
|
function niceTicks(min: number, max: number, count: number): number[] {
|
|
33
34
|
if (min === max) return [min];
|
|
@@ -77,18 +78,43 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
77
78
|
const refY = mode === "buy" ? listingPsqft ?? null : null;
|
|
78
79
|
const tMin = Math.min(...ts);
|
|
79
80
|
const tMax = Math.max(...ts);
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
|
|
82
|
+
// Least-squares trend over comparable units (fallback to all) — the tendency.
|
|
83
|
+
const comp = pts.filter((p) => p.comparable);
|
|
84
|
+
const fitSet = comp.length >= 2 ? comp : pts;
|
|
85
|
+
let trend: { y0: number; y1: number; annualPct: number | null } | null = null;
|
|
86
|
+
if (fitSet.length >= 2 && tMax > tMin) {
|
|
87
|
+
const n = fitSet.length;
|
|
88
|
+
const mt = fitSet.reduce((s, p) => s + p.t, 0) / n;
|
|
89
|
+
const my = fitSet.reduce((s, p) => s + p.y, 0) / n;
|
|
90
|
+
let num = 0;
|
|
91
|
+
let den = 0;
|
|
92
|
+
for (const p of fitSet) {
|
|
93
|
+
num += (p.t - mt) * (p.y - my);
|
|
94
|
+
den += (p.t - mt) ** 2;
|
|
95
|
+
}
|
|
96
|
+
const slope = den ? num / den : 0; // AED/sqft per ms
|
|
97
|
+
const intercept = my - slope * mt;
|
|
98
|
+
const YEAR = 365.25 * 24 * 3600 * 1000;
|
|
99
|
+
trend = {
|
|
100
|
+
y0: intercept + slope * tMin,
|
|
101
|
+
y1: intercept + slope * tMax,
|
|
102
|
+
annualPct: my ? (slope * YEAR) / my : null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const extra = [...(refY ? [refY] : []), ...(trend ? [trend.y0, trend.y1] : [])];
|
|
107
|
+
let yMin = Math.min(...ys, ...extra);
|
|
108
|
+
let yMax = Math.max(...ys, ...extra);
|
|
82
109
|
const pad = (yMax - yMin) * 0.1 || yMax * 0.1 || 1;
|
|
83
110
|
yMin = Math.max(0, yMin - pad);
|
|
84
111
|
yMax = yMax + pad;
|
|
85
112
|
const x = (t: number) => (tMax === tMin ? IW / 2 : ((t - tMin) / (tMax - tMin)) * IW);
|
|
86
113
|
const y = (v: number) => IH - ((v - yMin) / (yMax - yMin)) * IH;
|
|
87
114
|
// median of comparable set (fallback to all)
|
|
88
|
-
const comp = pts.filter((p) => p.comparable);
|
|
89
115
|
const base = (comp.length ? comp : pts).map((p) => p.y).sort((a, b) => a - b);
|
|
90
116
|
const median = base.length ? base[Math.floor(base.length / 2)] : null;
|
|
91
|
-
return { x, y, tMin, tMax, yMin, yMax, refY, median };
|
|
117
|
+
return { x, y, tMin, tMax, yMin, yMax, refY, median, trend };
|
|
92
118
|
}, [pts, mode, listingPsqft]);
|
|
93
119
|
|
|
94
120
|
const compCount = pts.filter((p) => p.comparable).length;
|
|
@@ -181,24 +207,44 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
181
207
|
</>
|
|
182
208
|
)}
|
|
183
209
|
|
|
184
|
-
{/*
|
|
210
|
+
{/* trend (least-squares) line */}
|
|
211
|
+
{scales.trend && (
|
|
212
|
+
<line
|
|
213
|
+
x1={scales.x(scales.tMin)}
|
|
214
|
+
x2={scales.x(scales.tMax)}
|
|
215
|
+
y1={scales.y(scales.trend.y0)}
|
|
216
|
+
y2={scales.y(scales.trend.y1)}
|
|
217
|
+
stroke={TREND}
|
|
218
|
+
strokeWidth={2.5}
|
|
219
|
+
strokeLinecap="round"
|
|
220
|
+
/>
|
|
221
|
+
)}
|
|
222
|
+
|
|
223
|
+
{/* points: muted first, comparable on top; small dot + invisible hit area */}
|
|
185
224
|
{pts
|
|
186
225
|
.slice()
|
|
187
226
|
.sort((a, b) => Number(a.comparable) - Number(b.comparable))
|
|
188
227
|
.map((p, i) => (
|
|
189
|
-
<
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
228
|
+
<g key={i}>
|
|
229
|
+
<circle
|
|
230
|
+
cx={scales.x(p.t)}
|
|
231
|
+
cy={scales.y(p.y)}
|
|
232
|
+
r={p.comparable ? 3 : 2}
|
|
233
|
+
fill={p.comparable ? ACCENT : MUTED}
|
|
234
|
+
stroke="#fff"
|
|
235
|
+
strokeWidth={p.comparable ? 1 : 0.5}
|
|
236
|
+
opacity={p.comparable ? 1 : 0.7}
|
|
237
|
+
/>
|
|
238
|
+
<circle
|
|
239
|
+
cx={scales.x(p.t)}
|
|
240
|
+
cy={scales.y(p.y)}
|
|
241
|
+
r={8}
|
|
242
|
+
fill="transparent"
|
|
243
|
+
onMouseEnter={() => setHover({ p, x: scales.x(p.t), y: scales.y(p.y) })}
|
|
244
|
+
onMouseLeave={() => setHover(null)}
|
|
245
|
+
style={{ cursor: "pointer" }}
|
|
246
|
+
/>
|
|
247
|
+
</g>
|
|
202
248
|
))}
|
|
203
249
|
</g>
|
|
204
250
|
</svg>
|
|
@@ -235,6 +281,16 @@ export default function PsqftChart({ buy, rent, listingBedrooms, listingPsqft }:
|
|
|
235
281
|
<span className="inline-block h-0 w-4 border-t-[1.5px] border-dashed" style={{ borderColor: MUTED }} />
|
|
236
282
|
Mediana
|
|
237
283
|
</span>
|
|
284
|
+
{scales.trend && (
|
|
285
|
+
<span className="flex items-center gap-1.5">
|
|
286
|
+
<span className="inline-block h-0 w-4 border-t-[2.5px]" style={{ borderColor: TREND }} />
|
|
287
|
+
<span style={{ color: TREND }} className="font-semibold">
|
|
288
|
+
Tendencia
|
|
289
|
+
{scales.trend.annualPct != null &&
|
|
290
|
+
` (${scales.trend.annualPct >= 0 ? "+" : ""}${(scales.trend.annualPct * 100).toFixed(1)} %/año)`}
|
|
291
|
+
</span>
|
|
292
|
+
</span>
|
|
293
|
+
)}
|
|
238
294
|
{mode === "buy" && listingPsqft != null && (
|
|
239
295
|
<span className="flex items-center gap-1.5">
|
|
240
296
|
<span className="inline-block h-0 w-4 border-t-2" style={{ borderColor: REF }} />
|