Files
QOffice/src/features/qexcell/QExcell.tsx
T
Курнат Андрей 9bf4dd3672 Initial QOffice implementation
2026-06-01 05:31:05 +03:00

823 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMemo, useRef, useState } from "react";
import type { CSSProperties } from "react";
import {
BarChart3,
Bold,
Download,
Eraser,
FileText,
FileUp,
FunctionSquare,
Italic,
Link,
LineChart,
PaintBucket,
PieChart,
Plus,
Save,
Sigma,
Trash2,
Underline
} from "lucide-react";
import type { CellFormat, SheetChart, SheetChartType, Workbook } from "../../types";
import { cellId, columnLabelsForCount, evaluateCell, formatCellValueForDisplay, mergedCellInfo, summarizeColumn, usedSheetBounds } from "../../utils/spreadsheet";
import { downloadBlobFile, downloadTextFile } from "../../utils/download";
import { chartDataPoints, exportXlsxBlob, importXlsxFile } from "../../io/excellOffice";
import { replaceFileExtension } from "../../io/fileHelpers";
import { isDesktopOfficeBridgeAvailable, openDesktopOfficeFile, saveDesktopOfficeFile } from "../../io/desktopOfficeFiles";
import { exportPdfOrPrint } from "../../io/pdfExport";
import { useQOfficeCommand } from "../../hooks/useQOfficeCommand";
interface QExcellProps {
workbook: Workbook;
onChange: (workbook: Workbook) => void;
}
const extraEditableRows = 1;
const extraEditableColumns = 1;
const emptyHyperlinks: Record<string, string> = {};
const emptyCellFormats: Record<string, CellFormat> = {};
const emptyCharts: SheetChart[] = [];
const fillSwatches = [
{ color: "", label: "Без заливки" },
{ color: "FFF2CC", label: "Светло-желтая заливка" },
{ color: "D9EAD3", label: "Светло-зеленая заливка" },
{ color: "DDEBF7", label: "Светло-синяя заливка" },
{ color: "FCE4D6", label: "Светло-коралловая заливка" },
{ color: "EADCF8", label: "Светло-фиолетовая заливка" }
];
const numberFormatOptions = [
{ value: "", label: "Обычный" },
{ value: "0.00", label: "Число" },
{ value: "# ##0.00 ₽", label: "Деньги" },
{ value: "0%", label: "Процент" },
{ value: "dd.mm.yyyy", label: "Дата" },
{ value: "dd.mm.yyyy hh:mm", label: "Дата и время" }
];
const chartTypeOptions: Array<{ value: SheetChartType; label: string }> = [
{ value: "bar", label: "Столбчатая" },
{ value: "line", label: "Линейная" },
{ value: "pie", label: "Круговая" }
];
function normalizedLinkInput(value: string | null) {
const raw = value?.trim();
if (!raw) {
return "";
}
const candidate = /^[a-z][a-z0-9+.-]*:/i.test(raw) ? raw : `https://${raw}`;
try {
const url = new URL(candidate);
return ["http:", "https:", "mailto:", "tel:", "ftp:"].includes(url.protocol.toLowerCase()) ? url.href : "";
} catch {
window.alert("Введите корректный адрес ссылки.");
return "";
}
}
function normalizedFillColor(value?: string) {
const raw = value?.trim().replace(/^#/, "").toUpperCase() ?? "";
return /^[0-9A-F]{6}$/.test(raw) ? raw : "";
}
function normalizedCellFormat(format: CellFormat): CellFormat | undefined {
const fillColor = normalizedFillColor(format.fillColor);
const numberFormat = format.numberFormat?.trim();
const normalized: CellFormat = {
...(format.bold ? { bold: true } : {}),
...(format.italics ? { italics: true } : {}),
...(format.underline ? { underline: true } : {}),
...(fillColor ? { fillColor } : {}),
...(numberFormat ? { numberFormat } : {})
};
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function cellInputStyle(format?: CellFormat): CSSProperties {
return {
...(format?.bold ? { fontWeight: 700 } : {}),
...(format?.italics ? { fontStyle: "italic" } : {}),
...(format?.underline ? { textDecoration: "underline" } : {}),
...(format?.fillColor ? { backgroundColor: `#${format.fillColor}` } : {})
};
}
function normalizedChartRange(value: string) {
const raw = value.trim().replace(/\$/g, "").toUpperCase();
const [start, end = start] = raw.split(":");
if (!/^[A-Z]+[1-9][0-9]*$/.test(start) || !/^[A-Z]+[1-9][0-9]*$/.test(end)) {
return "";
}
return `${start}:${end}`;
}
function chartTypeIcon(type: SheetChartType) {
if (type === "line") {
return LineChart;
}
if (type === "pie") {
return PieChart;
}
return BarChart3;
}
function chartTypeLabel(type: SheetChartType) {
return chartTypeOptions.find((option) => option.value === type)?.label ?? "Столбчатая";
}
export function QExcell({ workbook, onChange }: QExcellProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedCell, setSelectedCell] = useState("A1");
const [selectedChartId, setSelectedChartId] = useState("");
const [chartTitle, setChartTitle] = useState("Итого по продуктам");
const [chartType, setChartType] = useState<SheetChartType>("bar");
const [chartLabelRange, setChartLabelRange] = useState("A2:A4");
const [chartValueRange, setChartValueRange] = useState("E2:E4");
const activeSheet = workbook.sheets.find((sheet) => sheet.id === workbook.activeSheet) ?? workbook.sheets[0] ?? { id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] };
const activeMergedCells = activeSheet.mergedCells ?? [];
const activeHyperlinks = activeSheet.hyperlinks ?? emptyHyperlinks;
const activeCellFormats = activeSheet.cellFormats ?? emptyCellFormats;
const activeCharts = activeSheet.charts ?? emptyCharts;
const selectedRawValue = activeSheet.cells[selectedCell] ?? "";
const selectedHyperlink = activeHyperlinks[selectedCell] ?? "";
const selectedCellFormat = activeCellFormats[selectedCell] ?? {};
const selectedFormattedValue = formatCellValueForDisplay(evaluateCell(selectedCell, activeSheet.cells), selectedCellFormat);
const boundsCells = useMemo(
() =>
[...Object.keys(activeHyperlinks), ...Object.keys(activeCellFormats)].reduce<Record<string, string>>(
(cells, address) => ({ ...cells, [address]: cells[address] || activeHyperlinks[address] || "format" }),
{ ...activeSheet.cells }
),
[activeCellFormats, activeHyperlinks, activeSheet.cells]
);
const sheetBounds = useMemo(() => usedSheetBounds(boundsCells, activeMergedCells), [activeMergedCells, boundsCells]);
const rows = useMemo(() => Array.from({ length: sheetBounds.rowCount + extraEditableRows }, (_, index) => index), [sheetBounds.rowCount]);
const visibleColumnLabels = useMemo(
() => columnLabelsForCount(sheetBounds.columnCount + extraEditableColumns),
[sheetBounds.columnCount]
);
const totals = useMemo(() => {
return summarizeColumn(activeSheet.cells, 4);
}, [activeSheet.cells]);
function updateCell(id: string, value: string) {
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) =>
sheet.id === activeSheet.id ? { ...sheet, cells: { ...sheet.cells, [id]: value } } : sheet
)
});
}
function updateHyperlink(id: string, href: string) {
const normalizedId = id.toUpperCase();
const nextHyperlinks = { ...activeHyperlinks };
if (href) {
nextHyperlinks[normalizedId] = href;
} else {
delete nextHyperlinks[normalizedId];
}
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, hyperlinks: nextHyperlinks } : sheet))
});
}
function updateCellFormat(id: string, formatPatch: CellFormat) {
const normalizedId = id.toUpperCase();
const nextFormats = { ...activeCellFormats };
const nextFormat = normalizedCellFormat({ ...(nextFormats[normalizedId] ?? {}), ...formatPatch });
if (nextFormat) {
nextFormats[normalizedId] = nextFormat;
} else {
delete nextFormats[normalizedId];
}
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, cellFormats: nextFormats } : sheet))
});
}
function toggleSelectedFormat(key: "bold" | "italics" | "underline") {
updateCellFormat(selectedCell, { [key]: !selectedCellFormat[key] });
}
function insertLink() {
const href = normalizedLinkInput(window.prompt("Введите адрес ссылки", selectedHyperlink));
if (!href) {
return;
}
const nextCells = selectedRawValue ? activeSheet.cells : { ...activeSheet.cells, [selectedCell]: href };
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) =>
sheet.id === activeSheet.id
? {
...sheet,
cells: nextCells,
hyperlinks: { ...activeHyperlinks, [selectedCell]: href }
}
: sheet
)
});
}
function clearSelectedCell() {
const nextCells = { ...activeSheet.cells };
const nextHyperlinks = { ...activeHyperlinks };
const nextFormats = { ...activeCellFormats };
delete nextCells[selectedCell];
delete nextHyperlinks[selectedCell];
delete nextFormats[selectedCell];
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) =>
sheet.id === activeSheet.id ? { ...sheet, cells: nextCells, hyperlinks: nextHyperlinks, cellFormats: nextFormats } : sheet
)
});
}
function updateActiveSheetName(name: string) {
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, name } : sheet))
});
}
function selectChart(chart: SheetChart) {
setSelectedChartId(chart.id);
setChartTitle(chart.title);
setChartType(chart.type);
setChartLabelRange(chart.labelRange);
setChartValueRange(chart.valueRange);
}
function resetChartForm() {
setSelectedChartId("");
setChartTitle("Итого по продуктам");
setChartType("bar");
setChartLabelRange("A2:A4");
setChartValueRange("E2:E4");
}
function upsertChart() {
const labelRange = normalizedChartRange(chartLabelRange);
const valueRange = normalizedChartRange(chartValueRange);
if (!labelRange || !valueRange) {
window.alert("Введите диапазоны диаграммы в формате A2:A4.");
return;
}
const nextChart: SheetChart = {
id: selectedChartId || `chart-${Date.now()}`,
type: chartType,
title: chartTitle.trim() || chartTypeLabel(chartType),
labelRange,
valueRange
};
const nextCharts = selectedChartId
? activeCharts.map((chart) => (chart.id === selectedChartId ? nextChart : chart))
: [...activeCharts, nextChart];
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, charts: nextCharts } : sheet))
});
setSelectedChartId(nextChart.id);
}
function deleteChart(id: string) {
const nextCharts = activeCharts.filter((chart) => chart.id !== id);
onChange({
...workbook,
sheets: workbook.sheets.map((sheet) => (sheet.id === activeSheet.id ? { ...sheet, charts: nextCharts } : sheet))
});
if (selectedChartId === id) {
resetChartForm();
}
}
function exportCsv() {
const exportBounds = usedSheetBounds(activeSheet.cells, activeMergedCells);
const exportRows = Array.from({ length: exportBounds.rowCount }, (_, index) => index);
const exportColumnLabels = columnLabelsForCount(exportBounds.columnCount);
const csv = exportRows
.map((row) =>
exportColumnLabels
.map((_, column) => {
const value = activeSheet.cells[cellId(column, row)] ?? "";
return `"${value.replace(/"/g, '""')}"`;
})
.join(",")
)
.join("\n");
downloadTextFile(workbook.title.replace(/\.qxlsx$/i, ".csv"), csv, "text/csv");
}
async function openXlsx(file: File | undefined) {
if (!file) {
return;
}
try {
const imported = await importXlsxFile(file);
onChange({
...workbook,
title: imported.title,
activeSheet: imported.sheets[0]?.id ?? "sheet-1",
sheets: imported.sheets.length > 0 ? imported.sheets : [{ id: "sheet-1", name: "Лист 1", cells: {}, mergedCells: [] }]
});
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось открыть XLSX.");
} finally {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
}
}
async function saveXlsx() {
try {
const fileName = replaceFileExtension(workbook.title, ".xlsx");
const blob = await exportXlsxBlob(workbook);
if (isDesktopOfficeBridgeAvailable()) {
await saveDesktopOfficeFile("xlsx", fileName, blob);
return;
}
downloadBlobFile(fileName, blob);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось сохранить XLSX.");
}
}
async function chooseXlsx() {
if (isDesktopOfficeBridgeAvailable()) {
const file = await openDesktopOfficeFile("xlsx");
await openXlsx(file ?? undefined);
return;
}
fileInputRef.current?.click();
}
async function exportPdf() {
try {
await exportPdfOrPrint(workbook.title);
} catch (error) {
window.alert(error instanceof Error ? error.message : "Не удалось экспортировать PDF.");
}
}
useQOfficeCommand({
open: chooseXlsx,
save: saveXlsx,
"export-pdf": exportPdf,
print: () => window.print()
});
return (
<div className="editor-layout sheet-layout">
<section className="spreadsheet-stage" aria-label="Редактор QExcell">
<div className="module-toolbar">
<input
ref={fileInputRef}
className="hidden-file-input"
type="file"
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => void openXlsx(event.target.files?.[0])}
aria-label="Открыть XLSX"
/>
<input
className="file-title-input"
value={workbook.title}
aria-label="Название книги"
onChange={(event) => onChange({ ...workbook, title: event.target.value })}
/>
<button className="compact-command" type="button" onClick={() => updateCell(selectedCell, "=SUM(B2:D2)")}>
<Sigma size={16} />
Сумма
</button>
<button className="compact-command" type="button" onClick={() => updateCell(selectedCell, "=AVG(B2:D2)")}>
<FunctionSquare size={16} />
Среднее
</button>
<button className="compact-command" type="button" onClick={clearSelectedCell}>
<Eraser size={16} />
Очистить
</button>
<button className="compact-command" type="button" onClick={insertLink}>
<Link size={16} />
Ссылка
</button>
<div className="cell-format-tools" aria-label="Формат ячейки">
<button
className={`icon-button format-toggle ${selectedCellFormat.bold ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSelectedFormat("bold")}
title="Полужирный"
aria-label="Полужирный"
>
<Bold size={16} />
</button>
<button
className={`icon-button format-toggle ${selectedCellFormat.italics ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSelectedFormat("italics")}
title="Курсив"
aria-label="Курсив"
>
<Italic size={16} />
</button>
<button
className={`icon-button format-toggle ${selectedCellFormat.underline ? "is-active" : ""}`.trim()}
type="button"
onClick={() => toggleSelectedFormat("underline")}
title="Подчеркнуть"
aria-label="Подчеркнуть"
>
<Underline size={16} />
</button>
<div className="fill-swatches" aria-label="Заливка">
<PaintBucket size={16} aria-hidden="true" />
{fillSwatches.map((swatch) => (
<button
key={swatch.label}
className={`fill-swatch ${swatch.color ? "" : "is-empty"} ${selectedCellFormat.fillColor === swatch.color || (!selectedCellFormat.fillColor && !swatch.color) ? "is-active" : ""}`.trim()}
type="button"
onClick={() => updateCellFormat(selectedCell, { fillColor: swatch.color })}
title={swatch.label}
aria-label={swatch.label}
style={swatch.color ? { backgroundColor: `#${swatch.color}` } : undefined}
/>
))}
</div>
</div>
<button className="compact-command" type="button" onClick={() => void chooseXlsx()}>
<FileUp size={16} />
Открыть XLSX
</button>
<button className="compact-command" type="button" onClick={() => void saveXlsx()}>
<Save size={16} />
Сохранить XLSX
</button>
<button className="compact-command" type="button" onClick={exportCsv}>
<Download size={16} />
Экспорт CSV
</button>
<button className="compact-command" type="button" onClick={() => void exportPdf()}>
<FileText size={16} />
Экспорт PDF
</button>
</div>
<div className="formula-bar">
<span>{selectedCell}</span>
<input
value={selectedRawValue}
aria-label="Строка формул"
onChange={(event) => updateCell(selectedCell, event.target.value)}
placeholder="Введите значение или формулу, например =SUM(B2:D2)"
/>
</div>
<div className="grid-wrap">
<table className="sheet-grid">
<thead>
<tr>
<th aria-label="Номера строк" />
{visibleColumnLabels.map((column) => (
<th key={column}>{column}</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row}>
<th>{row + 1}</th>
{visibleColumnLabels.map((_, column) => {
const id = cellId(column, row);
const merge = mergedCellInfo(id, activeMergedCells);
if (merge && !merge.isOrigin) {
return null;
}
const raw = activeSheet.cells[id] ?? "";
const evaluatedDisplay = raw.startsWith("=") ? evaluateCell(id, activeSheet.cells) : raw;
const hyperlink = activeHyperlinks[id] ?? "";
const cellFormat = activeCellFormats[id];
const display = formatCellValueForDisplay(evaluatedDisplay, cellFormat);
return (
<td
key={id}
className={`${selectedCell === id ? "is-selected" : ""} ${merge ? "is-merged" : ""} ${hyperlink ? "is-linked" : ""} ${cellFormat ? "is-formatted" : ""}`.trim()}
colSpan={merge?.columnSpan}
rowSpan={merge?.rowSpan}
>
<input
value={selectedCell === id ? raw : display}
aria-label={`Ячейка ${id}`}
title={hyperlink || undefined}
style={cellInputStyle(cellFormat)}
onFocus={() => setSelectedCell(id)}
onChange={(event) => updateCell(id, event.target.value)}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{activeCharts.length > 0 ? (
<div className="chart-board" aria-label="Диаграммы листа">
{activeCharts.map((chart) => (
<SheetChartPreview
key={chart.id}
chart={chart}
cells={activeSheet.cells}
selected={chart.id === selectedChartId}
onSelect={() => selectChart(chart)}
onDelete={() => deleteChart(chart.id)}
/>
))}
</div>
) : null}
</section>
<aside className="inspector" aria-label="Сводка книги">
<section>
<h3>Книга</h3>
<label>
Лист
<select value={workbook.activeSheet} onChange={(event) => onChange({ ...workbook, activeSheet: event.target.value })}>
{workbook.sheets.map((sheet) => (
<option key={sheet.id} value={sheet.id}>
{sheet.name}
</option>
))}
</select>
</label>
<button
className="wide-command"
type="button"
onClick={() => {
const id = `sheet-${workbook.sheets.length + 1}`;
onChange({
...workbook,
activeSheet: id,
sheets: [...workbook.sheets, { id, name: `Лист ${workbook.sheets.length + 1}`, cells: {}, mergedCells: [] }]
});
}}
>
<Plus size={16} />
Новый лист
</button>
<label>
Имя листа
<input value={activeSheet.name} onChange={(event) => updateActiveSheetName(event.target.value)} />
</label>
</section>
<section>
<h3>Выделение</h3>
<p className="stat-line">{selectedCell}</p>
<p className="muted">Значение: {selectedRawValue || "пусто"}</p>
<p className="muted">Расчет: {selectedFormattedValue || "пусто"}</p>
<label>
Формат числа
<select value={selectedCellFormat.numberFormat ?? ""} onChange={(event) => updateCellFormat(selectedCell, { numberFormat: event.target.value })}>
{numberFormatOptions.map((option) => (
<option key={option.label} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Ссылка
<input
value={selectedHyperlink}
placeholder="https://example.com"
onChange={(event) => updateHyperlink(selectedCell, event.target.value)}
onBlur={(event) => updateHyperlink(selectedCell, normalizedLinkInput(event.target.value))}
/>
</label>
<button className="wide-command" type="button" onClick={insertLink}>
<Link size={16} />
Вставить ссылку
</button>
<p className="muted">Объединённых диапазонов: {activeMergedCells.length}</p>
</section>
<section>
<h3>Диаграммы</h3>
<label>
Заголовок
<input value={chartTitle} onChange={(event) => setChartTitle(event.target.value)} />
</label>
<label>
Тип
<select value={chartType} onChange={(event) => setChartType(event.target.value as SheetChartType)}>
{chartTypeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Подписи
<input value={chartLabelRange} onChange={(event) => setChartLabelRange(event.target.value)} placeholder="A2:A4" />
</label>
<label>
Значения
<input value={chartValueRange} onChange={(event) => setChartValueRange(event.target.value)} placeholder="E2:E4" />
</label>
<button className="wide-command" type="button" onClick={upsertChart}>
<BarChart3 size={16} />
{selectedChartId ? "Сохранить диаграмму" : "Добавить диаграмму"}
</button>
{selectedChartId ? (
<button className="wide-command" type="button" onClick={resetChartForm}>
<Plus size={16} />
Новая диаграмма
</button>
) : null}
<p className="muted">Диаграмм на листе: {activeCharts.length}</p>
</section>
<section>
<h3>Сводка</h3>
<p className="big-number">{totals.toLocaleString("ru-RU")}</p>
<p className="muted">Сумма числовых значений в столбце E.</p>
</section>
</aside>
</div>
);
}
function SheetChartPreview({
chart,
cells,
selected,
onSelect,
onDelete
}: {
chart: SheetChart;
cells: Record<string, string>;
selected: boolean;
onSelect: () => void;
onDelete: () => void;
}) {
const Icon = chartTypeIcon(chart.type);
const points = chartDataPoints(chart, cells);
return (
<article className={`sheet-chart ${selected ? "is-selected" : ""}`.trim()} onClick={onSelect}>
<header>
<div>
<Icon size={18} />
<strong>{chart.title}</strong>
</div>
<button
className="icon-button"
type="button"
title="Удалить диаграмму"
aria-label={`Удалить диаграмму ${chart.title}`}
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
>
<Trash2 size={16} />
</button>
</header>
<ChartSvg type={chart.type} points={points} />
<p className="muted">
{chartTypeLabel(chart.type)} · {chart.labelRange} / {chart.valueRange}
</p>
</article>
);
}
function ChartSvg({ type, points }: { type: SheetChartType; points: Array<{ label: string; value: number }> }) {
if (points.length === 0) {
return (
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Пустая диаграмма">
<text x="180" y="95" textAnchor="middle">
Нет данных
</text>
</svg>
);
}
if (type === "pie") {
return <PieChartSvg points={points} />;
}
if (type === "line") {
return <LineChartSvg points={points} />;
}
return <BarChartSvg points={points} />;
}
function BarChartSvg({ points }: { points: Array<{ label: string; value: number }> }) {
const max = Math.max(1, ...points.map((point) => Math.abs(point.value)));
const width = 320 / points.length;
return (
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Столбчатая диаграмма">
<line x1="28" y1="142" x2="346" y2="142" />
{points.map((point, index) => {
const barHeight = Math.round((Math.abs(point.value) / max) * 112);
const x = 32 + index * width;
return (
<g key={`${point.label}-${index}`}>
<rect x={x + 8} y={142 - barHeight} width={Math.max(16, width - 18)} height={barHeight} rx="4" />
<text x={x + width / 2} y="162" textAnchor="middle">
{point.label.slice(0, 10)}
</text>
</g>
);
})}
</svg>
);
}
function LineChartSvg({ points }: { points: Array<{ label: string; value: number }> }) {
const max = Math.max(1, ...points.map((point) => Math.abs(point.value)));
const step = points.length > 1 ? 300 / (points.length - 1) : 0;
const coordinates = points.map((point, index) => {
const x = points.length > 1 ? 30 + index * step : 180;
const y = 142 - (Math.abs(point.value) / max) * 112;
return { x, y, label: point.label };
});
const path = coordinates.map((point) => `${point.x},${point.y}`).join(" ");
return (
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Линейная диаграмма">
<line x1="28" y1="142" x2="346" y2="142" />
<polyline points={path} />
{coordinates.map((point, index) => (
<g key={`${point.label}-${index}`}>
<circle cx={point.x} cy={point.y} r="4" />
<text x={point.x} y="162" textAnchor="middle">
{point.label.slice(0, 10)}
</text>
</g>
))}
</svg>
);
}
function PieChartSvg({ points }: { points: Array<{ label: string; value: number }> }) {
const total = points.reduce((sum, point) => sum + Math.max(0, point.value), 0) || 1;
let startAngle = -90;
return (
<svg className="chart-svg" viewBox="0 0 360 180" role="img" aria-label="Круговая диаграмма">
{points.map((point, index) => {
const value = Math.max(0, point.value);
const angle = (value / total) * 360;
const path = pieSlicePath(95, 90, 58, startAngle, startAngle + angle);
startAngle += angle;
return <path key={`${point.label}-${index}`} d={path} className={`pie-slice slice-${index % 6}`} />;
})}
{points.slice(0, 5).map((point, index) => (
<g key={`${point.label}-${index}`} className="pie-legend">
<rect x="190" y={42 + index * 20} width="10" height="10" className={`pie-slice slice-${index % 6}`} />
<text x="208" y={52 + index * 20}>
{point.label.slice(0, 18)}
</text>
</g>
))}
</svg>
);
}
function pieSlicePath(cx: number, cy: number, radius: number, startAngle: number, endAngle: number) {
const start = polarToCartesian(cx, cy, radius, endAngle);
const end = polarToCartesian(cx, cy, radius, startAngle);
const largeArcFlag = endAngle - startAngle <= 180 ? "0" : "1";
return `M ${cx} ${cy} L ${start.x} ${start.y} A ${radius} ${radius} 0 ${largeArcFlag} 0 ${end.x} ${end.y} Z`;
}
function polarToCartesian(cx: number, cy: number, radius: number, angle: number) {
const radians = (angle * Math.PI) / 180;
return {
x: cx + radius * Math.cos(radians),
y: cy + radius * Math.sin(radians)
};
}