Initial QOffice implementation

This commit is contained in:
Курнат Андрей
2026-06-01 05:31:05 +03:00
commit 9bf4dd3672
44 changed files with 16854 additions and 0 deletions
+822
View File
@@ -0,0 +1,822 @@
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)
};
}