Starting blog post on stackoverflow

And loads of supporting graph code
This commit is contained in:
Dan 2026-01-04 15:31:47 +00:00
parent 8fdc28e7c5
commit 4233c43102
16 changed files with 1352 additions and 50 deletions

View file

@ -0,0 +1,350 @@
{{- $id := .Get "id" | default (printf "chart-%d" now.UnixNano) -}}
{{- $type := .Get "type" | default "line" -}}
{{- $title := .Get "title" | default "" -}}
{{- $height := .Get "height" | default "400" -}}
{{- $csvFile := .Get "csv" -}}
{{- $labelCol := .Get "labelColumn" | default "Month" -}}
{{- $dataCol := .Get "dataColumn" | default "Questions" -}}
{{- $dataColumns := .Get "dataColumns" | default "" -}}
{{- $datasetLabels := .Get "datasetLabels" | default "" -}}
{{- $yAxisIDs := .Get "yAxisIDs" | default "" -}}
{{- $dateFormat := .Get "dateFormat" | default "2006-01" -}}
{{- $skipRows := .Get "skipRows" | default 0 | int -}}
{{- $maxRows := .Get "maxRows" | default 0 | int -}}
{{- $chartData := dict -}}
{{- if $csvFile -}}
{{/* CSV file mode - supports multiple CSV files */}}
{{- $csvFiles := strings.Split $csvFile "," -}}
{{- $dataColsList := slice -}}
{{- if ne $dataColumns "" -}}
{{- $dataColsList = strings.Split $dataColumns "," -}}
{{- end -}}
{{- $labelsList := slice -}}
{{- if ne $datasetLabels "" -}}
{{- $labelsList = strings.Split $datasetLabels "," -}}
{{- end -}}
{{- $yAxisList := slice -}}
{{- if ne $yAxisIDs "" -}}
{{- $yAxisList = strings.Split $yAxisIDs "," -}}
{{- end -}}
{{- $labels := slice -}}
{{- $datasets := slice -}}
{{/* Process each CSV file */}}
{{- range $fileIdx, $csvFileName := $csvFiles -}}
{{- $csvFileName = strings.TrimSpace $csvFileName -}}
{{- $csvResource := $.Page.Resources.GetMatch $csvFileName -}}
{{- if not $csvResource -}}
{{- errorf "CSV file '%s' not found in page bundle for %s. Make sure the file exists in the same directory as index.md" $csvFileName $.Page.File.Path -}}
{{- end -}}
{{- $csvData := $csvResource | transform.Unmarshal -}}
{{- $data := slice -}}
{{/* Determine which data column to use */}}
{{- $currentDataCol := $dataCol -}}
{{- if ge $fileIdx (len $dataColsList) -}}
{{- $currentDataCol = $dataCol -}}
{{- else -}}
{{- $currentDataCol = strings.TrimSpace (index $dataColsList $fileIdx) -}}
{{- end -}}
{{/* Determine dataset label */}}
{{- $datasetLabel := $currentDataCol -}}
{{- if lt $fileIdx (len $labelsList) -}}
{{- $datasetLabel = strings.TrimSpace (index $labelsList $fileIdx) -}}
{{- end -}}
{{/* Determine Y-axis ID */}}
{{- $yAxisID := "y" -}}
{{- if lt $fileIdx (len $yAxisList) -}}
{{- $yAxisID = strings.TrimSpace (index $yAxisList $fileIdx) -}}
{{- end -}}
{{/* Process CSV rows */}}
{{- range $idx, $row := $csvData -}}
{{- if gt $idx $skipRows -}}
{{- if or (eq $maxRows 0) (le (len $data) $maxRows) -}}
{{/* Only set labels once from the first CSV file */}}
{{- if eq $fileIdx 0 -}}
{{/* Get label value */}}
{{- $labelRaw := "" -}}
{{- if reflect.IsMap $row -}}
{{- $labelRaw = index $row $labelCol -}}
{{- else -}}
{{/* Handle as slice/array - first column is label */}}
{{- $labelRaw = index $row 0 -}}
{{- end -}}
{{- $label := $labelRaw -}}
{{/* Try to parse and format date if it looks like a timestamp */}}
{{- $labelStr := printf "%v" $labelRaw -}}
{{- if strings.Contains $labelStr " 00:00:00" -}}
{{- $parsedTime := time.AsTime $labelStr -}}
{{- $label = $parsedTime.Format $dateFormat -}}
{{- end -}}
{{- $labels = $labels | append $label -}}
{{- end -}}
{{/* Get data value */}}
{{- $dataValue := 0 -}}
{{- if reflect.IsMap $row -}}
{{- $dataValue = index $row $currentDataCol | int -}}
{{- else -}}
{{/* Handle as slice/array - second column is data */}}
{{- $dataValue = index $row 1 | int -}}
{{- end -}}
{{- $data = $data | append $dataValue -}}
{{- end -}}
{{- end -}}
{{- end -}}
{{/* Add this dataset to the datasets array */}}
{{- $datasets = $datasets | append (dict "label" $datasetLabel "data" $data "yAxisID" $yAxisID) -}}
{{- end -}}
{{- $chartData = dict "labels" $labels "datasets" $datasets -}}
{{- else -}}
{{/* JSON inline mode */}}
{{- if .Inner -}}
{{- $chartData = .Inner | transform.Unmarshal -}}
{{- else -}}
{{- errorf "Graph shortcode requires either CSV file or inline JSON data" -}}
{{- end -}}
{{- end -}}
<div class="graph-container" style="height: {{ $height }}px;">
{{- if $title -}}
<h3 class="graph-title">{{ $title }}</h3>
{{- end -}}
<canvas id="{{ $id }}"></canvas>
</div>
<script>
(function() {
// Wait for Chart.js to load
function initChart() {
if (typeof Chart === 'undefined') {
setTimeout(initChart, 100);
return;
}
const ctx = document.getElementById('{{ $id }}').getContext('2d');
// Site color scheme - green, blue, amber
const colors = {
primary: 'rgb(0, 255, 0)', // green
secondary: 'rgb(0, 191, 255)', // blue/cyan
tertiary: 'rgb(255, 153, 0)', // amber/orange
quaternary: 'rgb(173, 255, 47)', // greenyellow
background: 'rgba(0, 255, 0, 0.1)',
grid: 'rgba(0, 255, 0, 0.2)',
text: 'rgb(0, 255, 0)'
};
const chartData = {{ $chartData | jsonify | safeJS }};
console.log('Chart ID: {{ $id }}');
console.log('Chart data:', chartData);
console.log('Labels count:', chartData.labels ? chartData.labels.length : 0);
console.log('Data count:', chartData.datasets && chartData.datasets[0] ? chartData.datasets[0].data.length : 0);
// Validate data
if (!chartData || !chartData.datasets || chartData.datasets.length === 0) {
console.error('Invalid chart data - no datasets found');
return;
}
// Check if any dataset uses the y1 axis
const usesY1Axis = chartData.datasets.some(dataset => dataset.yAxisID === 'y1');
// Apply terminal styling to datasets
if (chartData.datasets) {
chartData.datasets.forEach((dataset, idx) => {
const colorOptions = [colors.primary, colors.secondary, colors.tertiary, colors.quaternary];
const baseColor = colorOptions[idx % colorOptions.length];
console.log('Dataset', idx, 'using color:', baseColor);
if (!dataset.borderColor) {
dataset.borderColor = baseColor;
}
if (!dataset.backgroundColor && '{{ $type }}' === 'line') {
dataset.backgroundColor = baseColor.replace('rgb', 'rgba').replace(')', ', 0.1)');
} else if (!dataset.backgroundColor) {
dataset.backgroundColor = baseColor.replace('rgb', 'rgba').replace(')', ', 0.6)');
}
if (dataset.borderWidth === undefined) {
dataset.borderWidth = 2;
}
if (dataset.tension === undefined && '{{ $type }}' === 'line') {
dataset.tension = 0.1;
}
if (dataset.pointBackgroundColor === undefined && '{{ $type }}' === 'line') {
dataset.pointBackgroundColor = baseColor;
}
if (dataset.pointBorderColor === undefined && '{{ $type }}' === 'line') {
dataset.pointBorderColor = '#000';
}
if (dataset.pointRadius === undefined && '{{ $type }}' === 'line') {
dataset.pointRadius = 2;
}
if (dataset.pointHoverRadius === undefined && '{{ $type }}' === 'line') {
dataset.pointHoverRadius = 4;
}
});
}
// Build scales configuration
const scales = {
x: {
offset: true,
grid: {
color: colors.grid,
drawBorder: true,
borderColor: colors.secondary,
borderWidth: 2
},
ticks: {
color: colors.text,
font: {
family: 'monospace',
size: 10
},
maxRotation: 45,
minRotation: 45,
autoSkip: true,
maxTicksLimit: 20,
padding: 8
},
afterFit: function(scale) {
scale.paddingBottom = 20; // Extra space for rotated labels
}
},
y: {
type: 'linear',
position: 'left',
grid: {
color: colors.grid,
drawBorder: true,
borderColor: colors.secondary,
borderWidth: 2
},
ticks: {
color: colors.text,
font: {
family: 'monospace',
size: 11
},
padding: 5
}
}
};
// Only add y1 axis if it's actually used by any dataset
if (usesY1Axis) {
scales.y1 = {
type: 'linear',
position: 'right',
grid: {
drawOnChartArea: false, // Don't draw grid lines for secondary axis
drawBorder: true,
borderColor: colors.tertiary,
borderWidth: 2
},
ticks: {
color: colors.text,
font: {
family: 'monospace',
size: 11
},
padding: 5
}
};
}
const config = {
type: '{{ $type }}',
data: chartData,
options: {
responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
bottom: 30,
left: 10,
right: 10
}
},
plugins: {
legend: {
display: chartData.datasets && chartData.datasets.length > 1,
labels: {
color: colors.text,
font: {
family: 'monospace',
size: 12
},
boxWidth: 15,
padding: 10,
generateLabels: function(chart) {
const labels = Chart.defaults.plugins.legend.labels.generateLabels(chart);
labels.forEach(label => {
label.fillStyle = label.strokeStyle;
});
return labels;
}
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.9)',
titleColor: colors.primary,
bodyColor: colors.text,
borderColor: colors.secondary,
borderWidth: 1,
titleFont: {
family: 'monospace',
size: 14,
weight: 'bold'
},
bodyFont: {
family: 'monospace',
size: 12
},
padding: 12,
displayColors: true,
callbacks: {
labelColor: function(context) {
return {
borderColor: context.dataset.borderColor,
backgroundColor: context.dataset.borderColor
};
}
}
}
},
scales: scales,
animation: {
duration: 1000,
easing: 'easeOutQuart'
}
}
};
new Chart(ctx, config);
console.log('Chart initialized successfully');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initChart);
} else {
initChart();
}
})();
</script>