created chart block

This commit is contained in:
stfrigerio 2025-02-03 14:28:51 +01:00
parent 9e425ec19a
commit af908987ff
12 changed files with 446 additions and 2 deletions

View file

@ -13,7 +13,7 @@ import {
import { DBService } from "./src/dbService";
import { inspectTableStructure, convertEntriesInNotes } from "./src/commands";
import { pickTableName, processSqlBlock } from "./src/helpers";
import { pickTableName, processSqlBlock, processSqlChartBlock } from "./src/helpers";
import { SqliteDBSettings, DEFAULT_SETTINGS } from "./src/types";
export default class SqliteDBPlugin extends Plugin {
@ -62,6 +62,13 @@ export default class SqliteDBPlugin extends Plugin {
}
);
this.registerMarkdownCodeBlockProcessor(
"sql-chart",
async (source: string, el: HTMLElement, ctx: MarkdownPostProcessorContext) => {
await processSqlChartBlock(this.dbService, source, el);
}
);
this.addSettingTab(new SqliteDBSettingTab(this.app, this));
}

View file

@ -1,4 +1,5 @@
import { pickTableName } from "./pickTableName";
import { processSqlBlock } from "./processSqlBlock";
import { processSqlChartBlock } from "./processSqlChartBlock/index";
export { pickTableName, processSqlBlock };
export { pickTableName, processSqlBlock, processSqlChartBlock };

View file

@ -0,0 +1,29 @@
import { getColorForIndex } from "../helpers/getColorForIndex";
interface BarChartOptions {
stacked?: boolean;
}
export function createBarChart(labels: string[], datasets: any[], options?: BarChartOptions) {
return {
type: 'bar',
data: {
labels,
datasets: datasets.map((dataset, index) => ({
...dataset,
backgroundColor: getColorForIndex(index, 0.6),
}))
},
options: {
scales: {
y: {
beginAtZero: true,
stacked: options?.stacked
},
x: {
stacked: options?.stacked
}
}
}
};
}

View file

@ -0,0 +1,124 @@
interface LineChartOptions {
fill?: boolean;
tension?: number;
pointRadius?: number;
pointHoverRadius?: number;
showLegend?: boolean;
yAxisMin?: number;
yAxisMax?: number;
tooltips?: boolean;
animations?: boolean;
}
export function createLineChart(labels: string[], datasets: any[], options: LineChartOptions = {}) {
const defaultOptions: LineChartOptions = {
fill: false,
tension: 0.3,
pointRadius: 3,
pointHoverRadius: 5,
showLegend: true,
tooltips: true,
animations: true
};
const chartOptions = { ...defaultOptions, ...options };
return {
type: 'line',
data: {
labels,
datasets: datasets.map((dataset, index) => ({
...dataset,
fill: chartOptions.fill,
tension: chartOptions.tension,
pointRadius: chartOptions.pointRadius,
pointHoverRadius: chartOptions.pointHoverRadius,
borderWidth: 2,
// Point styling
pointBackgroundColor: dataset.borderColor,
pointBorderColor: dataset.borderColor,
pointBorderWidth: 1,
// Hover effects
hoverBorderWidth: 2,
hoverBorderColor: dataset.borderColor,
}))
},
options: {
responsive: true,
// maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index',
},
animations: chartOptions.animations ? {
tension: {
duration: 1000,
easing: 'linear',
from: 0.4,
to: 0.3,
}
} : false,
plugins: {
legend: {
display: chartOptions.showLegend,
position: 'top' as const,
labels: {
usePointStyle: true,
padding: 15,
font: {
size: 12
}
}
},
tooltip: {
enabled: chartOptions.tooltips,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleFont: {
size: 13
},
bodyFont: {
size: 12
},
padding: 10,
cornerRadius: 4,
displayColors: true
}
},
scales: {
x: {
grid: {
display: true,
drawBorder: true,
drawOnChartArea: true,
drawTicks: true,
color: 'rgba(0, 0, 0, 0.1)'
},
ticks: {
font: {
size: 11
},
maxRotation: 45,
minRotation: 45
}
},
y: {
beginAtZero: true,
min: chartOptions.yAxisMin,
max: chartOptions.yAxisMax,
grid: {
display: true,
drawBorder: true,
drawOnChartArea: true,
drawTicks: true,
color: 'rgba(0, 0, 0, 0.1)'
},
ticks: {
font: {
size: 11
}
}
}
}
}
};
}

View file

@ -0,0 +1,16 @@
import { createLineChart } from './LineChart';
import { createBarChart } from './BarChart';
export type ChartType = 'line' | 'bar' | 'scatter' | 'pie';
export function createChart(type: ChartType, labels: string[], datasets: any[], options?: any) {
switch (type) {
case 'line':
return createLineChart(labels, datasets, options);
case 'bar':
return createBarChart(labels, datasets, options);
//todo add pie and others
default:
return createLineChart(labels, datasets); // fallback to line chart
}
}

View file

@ -0,0 +1,34 @@
import { ChartConfig } from "./parseChartParams";
export function buildSqlQuery(config: ChartConfig) {
let query, params = [];
if (config.groupBy) {
query = `
SELECT
${config.xColumn},
${config.groupBy},
${config.yColumns.join(', ')}
FROM "${config.table}"
`;
} else {
query = `
SELECT
${config.xColumn},
${config.yColumns.join(', ')}
FROM "${config.table}"
`;
}
if (config.startDate && config.endDate) {
query += ` WHERE ${config.xColumn} BETWEEN ? AND ?`;
params.push(config.startDate, config.endDate);
}
query += ` ORDER BY ${config.xColumn}`;
if (config.groupBy) {
query += `, ${config.groupBy}`;
}
return { query, params };
}

View file

@ -0,0 +1,11 @@
export function getColorForIndex(index: number, alpha: number = 1): string {
const colors = [
`rgba(255, 99, 132, ${alpha})`, // red
`rgba(54, 162, 235, ${alpha})`, // blue
`rgba(75, 192, 192, ${alpha})`, // green
`rgba(255, 206, 86, ${alpha})`, // yellow
`rgba(153, 102, 255, ${alpha})`, // purple
`rgba(255, 159, 64, ${alpha})`, // orange
];
return colors[index % colors.length];
}

View file

@ -0,0 +1,6 @@
import { parseChartParams } from "./parseChartParams";
import { validateColumns } from "./validateColumns";
import { buildSqlQuery } from "./buildSqlQuery";
import { processChartData } from "./processChartData";
export { parseChartParams, validateColumns, buildSqlQuery, processChartData };

View file

@ -0,0 +1,82 @@
export interface ChartOptions {
fill?: boolean;
tension?: number;
pointRadius?: number;
pointHoverRadius?: number;
showLegend?: boolean;
yAxisMin?: number;
yAxisMax?: number;
tooltips?: boolean;
animations?: boolean;
}
export interface ChartConfig {
table: string;
xColumn: string;
yColumns: string[];
groupBy?: string;
startDate?: string;
endDate?: string;
chartType: string;
chartOptions?: ChartOptions;
}
export function parseChartParams(source: string): ChartConfig | null {
const lines = source.split("\n");
const params: any = {};
let chartOptions: any = {};
let inChartOptions = false;
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
// Handle chartOptions block
if (trimmed === 'chartOptions: {') {
inChartOptions = true;
continue;
}
if (trimmed === '}') {
inChartOptions = false;
continue;
}
if (inChartOptions) {
// Parse chartOptions parameters
const optionMatch = trimmed.match(/(\w+):\s*([^,]+),?$/);
if (optionMatch) {
const [, key, valueStr] = optionMatch;
// Convert string values to appropriate types
let value: any = valueStr.trim();
if (value === 'true') value = true;
else if (value === 'false') value = false;
else if (!isNaN(Number(value))) value = Number(value);
chartOptions[key] = value;
}
continue;
}
// Handle regular parameters
const parts = trimmed.split(":").map((p) => p.trim());
if (parts.length >= 2) {
const key = parts[0];
const val = parts.slice(1).join(":").trim();
params[key] = val;
}
}
if (!params.table || !params.xColumn || !params.yColumns) {
return null;
}
return {
table: params.table,
xColumn: params.xColumn,
yColumns: params.yColumns.split(',').map((c: string) => c.trim()),
groupBy: params.groupBy?.trim(),
startDate: params.startDate,
endDate: params.endDate,
chartType: params.chartType || 'line',
chartOptions: Object.keys(chartOptions).length > 0 ? chartOptions : undefined
};
}

View file

@ -0,0 +1,58 @@
import { getColorForIndex } from './getColorForIndex';
import { ChartConfig } from './parseChartParams';
export function processChartData(rowObj: any, config: ChartConfig) {
let labels: any[];
let datasets: any[];
if (config.groupBy) {
const { labels: groupLabels, datasets: groupDatasets } = processGroupedData(rowObj, config);
labels = groupLabels;
datasets = groupDatasets;
} else {
labels = rowObj.values.map((row: any[]) => row[0]);
datasets = processUngroupedData(rowObj, config);
}
return { labels, datasets };
}
function processGroupedData(rowObj: any, config: ChartConfig) {
const groupedData = new Map();
rowObj.values.forEach((row: any[]) => {
const x = row[0];
const group = String(row[1]);
const y = row[2];
if (!groupedData.has(group)) {
groupedData.set(group, { x: [], y: [] });
}
groupedData.get(group).x.push(x);
groupedData.get(group).y.push(y);
});
const labels = Array.from(groupedData.values())[0].x;
const datasets = Array.from(groupedData.entries()).map(([group, data], index) => ({
label: group,
data: data.y,
borderColor: getColorForIndex(index),
backgroundColor: getColorForIndex(index, 0.2),
fill: false,
tension: 0.1
}));
return { labels, datasets };
}
function processUngroupedData(rowObj: any, config: ChartConfig) {
return config.yColumns.map((col, index) => ({
label: col,
data: rowObj.values.map((row: any[]) => row[index + 1]),
borderColor: getColorForIndex(index),
backgroundColor: getColorForIndex(index, 0.2),
fill: false,
tension: 0.1
}));
}

View file

@ -0,0 +1,21 @@
import { ChartConfig } from "./parseChartParams";
export async function validateColumns(db: any, config: ChartConfig) {
const tableInfo = db.exec(`PRAGMA table_info("${config.table}");`);
const availableColumns = tableInfo[0].values.map((row: any[]) => row[1]);
const requestedColumns = [config.xColumn, ...config.yColumns];
if (config.groupBy) {
requestedColumns.push(config.groupBy);
}
const invalidColumns = requestedColumns.filter(col => !availableColumns.includes(col));
if (invalidColumns.length > 0) {
return {
message: `The following columns do not exist: ${invalidColumns.join(", ")}`,
availableColumns
};
}
return null;
}

View file

@ -0,0 +1,55 @@
import { Notice } from "obsidian";
import { DBService } from "../../dbService";
import { parseChartParams, validateColumns, buildSqlQuery, processChartData } from "./helpers";
import { ChartType, createChart } from "./charts";
export async function processSqlChartBlock(dbService: DBService, source: string, el: HTMLElement) {
const db = dbService.getDB();
if (!db) {
new Notice("No DB loaded. Please open the DB first.");
el.createEl("p", { text: "Database not loaded." });
return;
}
const config = parseChartParams(source);
if (!config) {
el.createEl("p", { text: "Required parameters are: table, xColumn, yColumns" });
return;
}
try {
const validationError = await validateColumns(db, config);
if (validationError) {
el.createEl("p", { text: validationError.message });
if (validationError.availableColumns) {
el.createEl("p", { text: `Available columns are: ${validationError.availableColumns.join(", ")}` });
}
return;
}
const { query, params } = buildSqlQuery(config);
const result = db.exec(query, params);
if (!result || result.length === 0 || result[0].values.length === 0) {
el.createEl("p", { text: "No data found for the specified parameters." });
return;
}
const { labels, datasets } = processChartData(result[0], config);
const chartData = createChart(
config.chartType as ChartType,
labels,
datasets,
config.chartOptions
);
// @ts-ignore
window.renderChart(chartData, el);
} catch (error) {
el.createEl("p", { text: "An error occurred while processing the chart." });
el.createEl("p", { text: String(error) });
console.error(error);
}
}