Skip to content

Commit 431d8d8

Browse files
authored
Merge pull request #11 from ufal/frontend
Frontend UI Improvements
2 parents bfeb8c5 + 6367543 commit 431d8d8

7 files changed

Lines changed: 273 additions & 200 deletions

File tree

frontend/src/components/App.tsx

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import MainMenu from './MainMenu';
44
import GraphList from './GraphList';
55
import GraphConfigurator from './GraphConfigurator';
66
import './App.css';
7-
import { exportAllGraphs } from '../utils/fileUtils';
7+
import { exportGraphAsPNG } from '../utils/fileUtils';
88

99
const App: React.FC = () => {
1010

@@ -218,17 +218,10 @@ const App: React.FC = () => {
218218
}
219219
};
220220

221-
const handleAddFigure = (figureConfig: FigureConfig) => {
221+
const handleUpdateFigure = (figureConfig: FigureConfig) => {
222222
setState((prev) => ({
223223
...prev,
224-
figures: [...prev.figures, figureConfig],
225-
}));
226-
};
227-
228-
const handleRemoveFigure = (figureId: string) => {
229-
setState((prev) => ({
230-
...prev,
231-
figures: prev.figures.filter((f) => f.id !== figureId),
224+
figures: [figureConfig],
232225
}));
233226
};
234227

@@ -239,20 +232,29 @@ const App: React.FC = () => {
239232
}));
240233
};
241234

242-
const handleExportGraphs = async () => {
243-
const figuresForExport = state.figures.map((figure) => ({
244-
id: figure.id,
245-
title: figure.title,
246-
}));
247-
await exportAllGraphs(figuresForExport);
235+
const handleExportGraph = async () => {
236+
const figure = state.figures[0];
237+
if (!figure) {
238+
alert('No figure to export. Configure a figure first.');
239+
return;
240+
}
241+
const elementId = `graph-${figure.id}`;
242+
const baseName = `${state.datasetName || 'dataset'}-${figure.typeId}`.replace(/[^a-z0-9-_]/gi, '_');
243+
const filename = `${baseName}.png`;
244+
try {
245+
await exportGraphAsPNG(elementId, filename);
246+
} catch (e) {
247+
const msg = e instanceof Error ? e.message : String(e);
248+
alert(`Export failed: ${msg}`);
249+
}
248250
};
249251

250252
return (
251253
<div className="app">
252254
<MainMenu
253255
onLoadVisualization={handleLoadVisualization}
254256
onSaveVisualization={handleSaveVisualization}
255-
onExportGraphs={handleExportGraphs}
257+
onExportGraph={handleExportGraph}
256258
datasetName={state.datasetName}
257259
/>
258260
{importStatus && (
@@ -274,14 +276,13 @@ const App: React.FC = () => {
274276
<div className="content">
275277
<div className="graph-list-container">
276278
<GraphList
277-
figures={state.figures}
279+
figure={state.figures[0]}
278280
data={state.data}
279-
onRemoveFigure={handleRemoveFigure}
280281
/>
281282
</div>
282283
<div className="configurator-container">
283284
<GraphConfigurator
284-
onAddFigure={handleAddFigure}
285+
onUpdateFigure={handleUpdateFigure}
285286
availableTokenizers={state.availableTokenizers}
286287
availableMetrics={state.availableMetrics}
287288
availableLanguages={state.availableLanguages}

frontend/src/components/Graph.tsx

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ import {
1212
Tooltip,
1313
Legend,
1414
ResponsiveContainer,
15+
ReferenceLine,
16+
ComposedChart,
17+
Line,
1518
} from 'recharts';
1619
import './Graph.css';
1720

@@ -169,9 +172,100 @@ const Graph: React.FC<GraphProps> = ({ config, data }) => {
169172
const metricX = metrics[0];
170173
const metricY = metrics[1];
171174

175+
const groupBy = config.groupBy || 'tokenizer';
176+
177+
const getLanguageInfoForLabel = (label: string): any => {
178+
const parts = label.split('_');
179+
const base = parts.length >= 3 ? parts.slice(0, parts.length - 2).join('_') : label;
180+
const glottocode = parts.length >= 3 ? parts[parts.length - 1] : undefined;
181+
const root: any = data?.metadata?.languagesInfo || {};
182+
const direct = root.languages && root.languages[base] ? root.languages[base]
183+
: (root[base] ? root[base] : null);
184+
if (direct) return direct;
185+
if (glottocode) {
186+
const entries: Array<{ key: string; info: any }> = root.languages && typeof root.languages === 'object'
187+
? Object.keys(root.languages).map((k) => ({ key: k, info: root.languages[k] }))
188+
: Object.keys(root).map((k) => ({ key: k, info: root[k] }));
189+
for (const { info } of entries) {
190+
const gc = info?.glottocodes;
191+
if (typeof gc === 'string' && gc === glottocode) return info;
192+
if (Array.isArray(gc) && gc.includes(glottocode)) return info;
193+
if (gc && typeof gc === 'object' && Object.keys(gc).includes(glottocode)) return info;
194+
}
195+
}
196+
return null;
197+
};
198+
199+
const getFamilyForLanguage = (label: string): string => {
200+
const info = getLanguageInfoForLabel(label);
201+
if (!info) return 'unknown';
202+
const fam = info.families;
203+
if (Array.isArray(fam)) return fam[0] || 'unknown';
204+
if (fam && typeof fam === 'object') {
205+
const keys = Object.keys(fam);
206+
return keys[0] || 'unknown';
207+
}
208+
if (typeof fam === 'string') return fam || 'unknown';
209+
return 'unknown';
210+
};
211+
212+
const groupKeyForPoint = (pt: any): string => {
213+
if (groupBy === 'tokenizer') return pt.tokenizer || 'unknown';
214+
if (groupBy === 'language') return pt.language || 'unknown';
215+
if (groupBy === 'family') return getFamilyForLanguage(pt.language || '');
216+
return 'unknown';
217+
};
218+
219+
// Partition chartData into groups
220+
const groupsMap: Map<string, any[]> = new Map();
221+
(Array.isArray(chartData) ? chartData : []).forEach((pt) => {
222+
const key = groupKeyForPoint(pt);
223+
if (!groupsMap.has(key)) groupsMap.set(key, []);
224+
groupsMap.get(key)!.push(pt);
225+
});
226+
const groupNames = Array.from(groupsMap.keys());
227+
228+
const allPoints: any[] = (Array.isArray(chartData) ? chartData : []).filter((pt) => {
229+
const x = pt[metricX];
230+
const y = pt[metricY];
231+
return typeof x === 'number' && typeof y === 'number' && !Number.isNaN(x) && !Number.isNaN(y) && isFinite(x) && isFinite(y);
232+
});
233+
234+
const computeTrend = (pts: any[]): { m: number; b: number; minX: number; maxX: number } | null => {
235+
if (!pts || pts.length < 2) return null;
236+
let sumX = 0, sumY = 0, sumXY = 0, sumXX = 0;
237+
let minX = Infinity, maxX = -Infinity;
238+
for (const p of pts) {
239+
const x = p[metricX];
240+
const y = p[metricY];
241+
sumX += x;
242+
sumY += y;
243+
sumXY += x * y;
244+
sumXX += x * x;
245+
if (x < minX) minX = x;
246+
if (x > maxX) maxX = x;
247+
}
248+
const n = pts.length;
249+
const denom = (n * sumXX - sumX * sumX);
250+
if (denom === 0) return null;
251+
const m = (n * sumXY - sumX * sumY) / denom;
252+
const b = (sumY - m * sumX) / n;
253+
return { m, b, minX, maxX };
254+
};
255+
256+
const trend = config.showTrendline ? computeTrend(allPoints) : null;
257+
258+
// Use ComposedChart to overlay a line on scatter
259+
const trendData = trend
260+
? [
261+
{ [metricX]: trend.minX, [metricY]: trend.m * trend.minX + trend.b },
262+
{ [metricX]: trend.maxX, [metricY]: trend.m * trend.maxX + trend.b },
263+
]
264+
: [];
265+
172266
return (
173-
<ResponsiveContainer width="100%" height={400}>
174-
<ScatterChart margin={{ top: 20, right: 30, left: 60, bottom: 60 }}>
267+
<ResponsiveContainer width="100%" height={420}>
268+
<ComposedChart margin={{ top: 20, right: 30, left: 60, bottom: 90 }}>
175269
<CartesianGrid strokeDasharray="3 3" />
176270
<XAxis
177271
type="number"
@@ -202,13 +296,29 @@ const Graph: React.FC<GraphProps> = ({ config, data }) => {
202296
return null;
203297
}}
204298
/>
205-
<Scatter
206-
name="Metric Pair Correlation"
207-
data={chartData}
208-
fill="#8884d8"
209-
isAnimationActive={false}
210-
/>
211-
</ScatterChart>
299+
<Legend verticalAlign="bottom" align="center" wrapperStyle={{ paddingTop: 10 }} />
300+
{trend && (
301+
<Line
302+
type="linear"
303+
data={trendData}
304+
dataKey={metricY}
305+
name="Trend"
306+
stroke="#444"
307+
strokeDasharray="4 2"
308+
dot={false}
309+
isAnimationActive={false}
310+
/>
311+
)}
312+
{groupNames.map((name, idx) => (
313+
<Scatter
314+
key={name}
315+
name={name}
316+
data={groupsMap.get(name)!}
317+
fill={getColorForMetric(idx)}
318+
isAnimationActive={false}
319+
/>
320+
))}
321+
</ComposedChart>
212322
</ResponsiveContainer>
213323
);
214324
};

0 commit comments

Comments
 (0)