The app becomes a Rails 8 application; the Vue frontend moves to app/frontend and is served through vite_rails. - Without an account documents stay in the browser (IndexedDB). Sign-in is by magic link or the 6-digit code from the same mail; afterwards local documents can be uploaded into the account. - Server documents (/d/<id>) with versioned saves and a single-editor lock per browser tab; others see them read-only and follow by polling. - Sharing via link (works without an account) and via e-mail invitation, roles viewer/editor; the owner manages links and people. - Store gains a read-only mode that blocks all content changes. - Solid Queue, Cache and Cable share the one PostgreSQL database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
264 lines
7.2 KiB
Vue
264 lines
7.2 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* One chart object. Chart.js (bar / line) over data derived live from its
|
|
* source table: categories = header row (from column B), one series per region
|
|
* (column A). Any grid edit that touches those cells re-renders immediately.
|
|
*/
|
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
import Chart from 'chart.js/auto';
|
|
import { useDocument } from '../store/document';
|
|
import { useTheme } from '../composables/useTheme';
|
|
import { downloadDataUrl } from '../io/download';
|
|
import { isNumeric, formatValue } from '../store/format';
|
|
|
|
const props = defineProps<{ objId: string }>();
|
|
|
|
const store = useDocument();
|
|
const { isDarkNow, mode: themeMode } = useTheme();
|
|
|
|
const obj = computed(() => {
|
|
const o = store.objectById(props.objId);
|
|
return o && o.kind === 'chart' ? o : undefined;
|
|
});
|
|
const canvas = ref<HTMLCanvasElement | null>(null);
|
|
let chart: Chart | null = null;
|
|
|
|
function cssVar(n: string) {
|
|
return getComputedStyle(document.documentElement).getPropertyValue(n).trim();
|
|
}
|
|
const palette = () => [
|
|
cssVar('--series-1'), cssVar('--series-2'), cssVar('--series-3'),
|
|
cssVar('--series-4'), cssVar('--series-5'),
|
|
];
|
|
|
|
const model = computed(() => {
|
|
void store.recalcTick.value;
|
|
const src = obj.value?.sourceTableId ? store.tableById(obj.value.sourceTableId) : undefined;
|
|
if (!src) return { quarters: [] as string[], regions: [] as string[], rows: [] as number[][] };
|
|
const quarters: string[] = [];
|
|
for (let c = 1; c < src.cols; c++) {
|
|
const v = store.cellValue(src.id, c, 0);
|
|
if (v === null || v === '') break;
|
|
quarters.push(String(v));
|
|
}
|
|
const regions: string[] = [];
|
|
const rows: number[][] = [];
|
|
for (let r = 1; r < src.rows; r++) {
|
|
const name = store.cellValue(src.id, 0, r);
|
|
if (name === null || name === '') break;
|
|
regions.push(String(name));
|
|
rows.push(
|
|
quarters.map((_, i) => {
|
|
const cv = store.cellValue(src.id, i + 1, r);
|
|
return isNumeric(cv) ? (cv as number) : 0;
|
|
}),
|
|
);
|
|
}
|
|
return { quarters, regions, rows };
|
|
});
|
|
|
|
const chartLocale = computed(() => store.effectiveLocale(props.objId));
|
|
|
|
function valueLabelPlugin(color: string): import('chart.js').Plugin {
|
|
return {
|
|
id: 'valueLabels',
|
|
afterDatasetsDraw(c) {
|
|
const { ctx } = c;
|
|
ctx.save();
|
|
ctx.font = '10px -apple-system, sans-serif';
|
|
ctx.fillStyle = color;
|
|
ctx.textAlign = 'center';
|
|
c.data.datasets.forEach((ds, di) => {
|
|
const meta = c.getDatasetMeta(di);
|
|
if (meta.hidden) return;
|
|
meta.data.forEach((pt, i) => {
|
|
const raw = ds.data[i];
|
|
if (typeof raw !== 'number') return;
|
|
ctx.fillText(formatValue(raw, chartLocale.value), pt.x, pt.y - 6);
|
|
});
|
|
});
|
|
ctx.restore();
|
|
},
|
|
};
|
|
}
|
|
|
|
function render() {
|
|
if (!canvas.value || !obj.value) return;
|
|
chart?.destroy();
|
|
const { quarters, regions, rows } = model.value;
|
|
const colors = palette();
|
|
const tick = obj.value.fg || (isDarkNow() ? '#a1a1a6' : '#6e6e73');
|
|
const grid = isDarkNow() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
|
|
const view = obj.value.view;
|
|
|
|
chart = new Chart(canvas.value, {
|
|
type: view,
|
|
data: {
|
|
labels: quarters,
|
|
datasets: regions.map((region, i) => ({
|
|
label: region,
|
|
data: rows[i],
|
|
backgroundColor: view === 'bar' ? colors[i % colors.length] : colors[i % colors.length] + '33',
|
|
borderColor: colors[i % colors.length],
|
|
borderWidth: 2,
|
|
pointRadius: view === 'line' ? 3 : 0,
|
|
tension: 0.3,
|
|
fill: false,
|
|
})),
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: { duration: 200 },
|
|
plugins: {
|
|
legend: { position: 'bottom', labels: { color: tick, boxWidth: 12 } },
|
|
tooltip: { enabled: true },
|
|
},
|
|
scales: {
|
|
x: { ticks: { color: tick }, grid: { color: grid } },
|
|
y: { ticks: { color: tick }, grid: { color: grid }, beginAtZero: true },
|
|
},
|
|
},
|
|
plugins: [valueLabelPlugin(tick)],
|
|
});
|
|
}
|
|
|
|
onMounted(render);
|
|
onBeforeUnmount(() => chart?.destroy());
|
|
watch(
|
|
[
|
|
model,
|
|
() => obj.value?.view,
|
|
() => obj.value?.width,
|
|
() => obj.value?.height,
|
|
() => obj.value?.fg,
|
|
themeMode,
|
|
chartLocale,
|
|
],
|
|
() => {
|
|
// resize needs a re-render because maintainAspectRatio is false
|
|
render();
|
|
},
|
|
{ deep: true },
|
|
);
|
|
|
|
const panelStyle = computed(() => (obj.value?.bg ? { background: obj.value.bg } : {}));
|
|
|
|
function exportPng() {
|
|
if (chart) downloadDataUrl(`${obj.value?.name ?? 'diagramm'}.png`, chart.toBase64Image());
|
|
}
|
|
defineExpose({ exportPng });
|
|
|
|
// allow the toolbar's "Export gewähltes Diagramm" to reach the active chart
|
|
function onExportEvent(e: Event) {
|
|
if ((e as CustomEvent).detail === props.objId) exportPng();
|
|
}
|
|
onMounted(() => window.addEventListener('export-chart-png', onExportEvent));
|
|
onBeforeUnmount(() => window.removeEventListener('export-chart-png', onExportEvent));
|
|
|
|
const tableOptions = computed(() =>
|
|
store.doc.sheets.flatMap((s) => s.objects)
|
|
.filter((o) => o.kind === 'table')
|
|
.map((o) => ({ id: o.id, name: o.name })),
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="obj" class="chart-view" :style="panelStyle">
|
|
<div class="bar">
|
|
<div class="seg">
|
|
<button :class="{ on: obj.view === 'bar' }" @click.stop="store.setChartView(objId, 'bar')">Balken</button>
|
|
<button :class="{ on: obj.view === 'line' }" @click.stop="store.setChartView(objId, 'line')">Linie</button>
|
|
</div>
|
|
<select
|
|
class="src"
|
|
:value="obj.sourceTableId ?? ''"
|
|
@change="store.setChartSource(objId, ($event.target as HTMLSelectElement).value)"
|
|
@mousedown.stop
|
|
>
|
|
<option v-for="o in tableOptions" :key="o.id" :value="o.id">{{ o.name }}</option>
|
|
</select>
|
|
<span class="sp" />
|
|
<button class="ghost" @click.stop="exportPng">PNG</button>
|
|
</div>
|
|
<div class="body">
|
|
<canvas ref="canvas" />
|
|
<p v-if="model.regions.length === 0" class="empty">Keine Regions-/Quartalsdaten in der Quelltabelle.</p>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.chart-view {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100%;
|
|
height: 100%;
|
|
background: var(--surface);
|
|
}
|
|
.bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 8px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.sp {
|
|
flex: 1;
|
|
}
|
|
.seg {
|
|
display: inline-flex;
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
overflow: hidden;
|
|
}
|
|
.seg button {
|
|
border: 0;
|
|
background: var(--surface);
|
|
padding: 3px 10px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
}
|
|
.seg button.on {
|
|
background: var(--accent);
|
|
color: #fff;
|
|
}
|
|
.src {
|
|
border: 1px solid var(--border);
|
|
border-radius: 6px;
|
|
padding: 2px 6px;
|
|
background: var(--surface);
|
|
color: var(--text);
|
|
font-size: 11px;
|
|
max-width: 130px;
|
|
}
|
|
.ghost {
|
|
border: 1px solid var(--border);
|
|
background: var(--surface);
|
|
border-radius: 6px;
|
|
padding: 3px 9px;
|
|
font-size: 11px;
|
|
cursor: pointer;
|
|
}
|
|
.ghost:hover {
|
|
border-color: var(--accent);
|
|
color: var(--accent);
|
|
}
|
|
.body {
|
|
position: relative;
|
|
flex: 1;
|
|
min-height: 0;
|
|
padding: 10px;
|
|
}
|
|
.empty {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: grid;
|
|
place-items: center;
|
|
color: var(--text-dim);
|
|
font-size: 12px;
|
|
text-align: center;
|
|
padding: 12px;
|
|
}
|
|
</style>
|