mirror of
https://github.com/ClusterCockpit/cc-backend
synced 2025-07-27 06:36:07 +02:00
Import svelte web frontend
This commit is contained in:
210
web/frontend/src/plots/Histogram.svelte
Normal file
210
web/frontend/src/plots/Histogram.svelte
Normal file
@@ -0,0 +1,210 @@
|
||||
<!--
|
||||
@component
|
||||
Properties:
|
||||
- width, height: Number
|
||||
- min, max: Number
|
||||
- label: (x-Value) => String
|
||||
- data: [{ value: Number, count: Number }]
|
||||
-->
|
||||
|
||||
<div
|
||||
on:mousemove={mousemove}
|
||||
on:mouseleave={() => (infoText = '')}>
|
||||
<span style="left: {paddingLeft + 5}px;">{infoText}</span>
|
||||
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
export let data
|
||||
export let width
|
||||
export let height
|
||||
export let min = null
|
||||
export let max = null
|
||||
export let label = formatNumber
|
||||
|
||||
const fontSize = 12
|
||||
const fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
||||
const paddingLeft = 35, paddingRight = 20, paddingTop = 20, paddingBottom = 20
|
||||
|
||||
let ctx, canvasElement
|
||||
|
||||
const maxCount = data.reduce((max, point) => Math.max(max, point.count), 0),
|
||||
maxValue = data.reduce((max, point) => Math.max(max, point.value), 0.1)
|
||||
|
||||
function getStepSize(valueRange, pixelRange, minSpace) {
|
||||
const proposition = valueRange / (pixelRange / minSpace)
|
||||
const getStepSize = n => Math.pow(10, Math.floor(n / 3)) *
|
||||
(n < 0 ? [1., 5., 2.][-n % 3] : [1., 2., 5.][n % 3])
|
||||
|
||||
let n = 0
|
||||
let stepsize = getStepSize(n)
|
||||
while (true) {
|
||||
let bigger = getStepSize(n + 1)
|
||||
if (proposition > bigger) {
|
||||
n += 1
|
||||
stepsize = bigger
|
||||
} else {
|
||||
return stepsize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let infoText = ''
|
||||
function mousemove(event) {
|
||||
let rect = event.target.getBoundingClientRect()
|
||||
let x = event.clientX - rect.left
|
||||
if (x < paddingLeft || x > width - paddingRight) {
|
||||
infoText = ''
|
||||
return
|
||||
}
|
||||
|
||||
const w = width - paddingLeft - paddingRight
|
||||
const barWidth = Math.round(w / (maxValue + 1))
|
||||
x = Math.floor((x - paddingLeft) / (w - barWidth) * maxValue)
|
||||
let point = data.find(point => point.value == x)
|
||||
|
||||
if (point)
|
||||
infoText = `count: ${point.count} (value: ${label(x)})`
|
||||
else
|
||||
infoText = ''
|
||||
}
|
||||
|
||||
function render() {
|
||||
const h = height - paddingTop - paddingBottom
|
||||
const w = width - paddingLeft - paddingRight
|
||||
const barWidth = Math.ceil(w / (maxValue + 1))
|
||||
|
||||
if (Number.isNaN(barWidth))
|
||||
return
|
||||
|
||||
const getCanvasX = (value) => (value / maxValue) * (w - barWidth) + paddingLeft + (barWidth / 2.)
|
||||
const getCanvasY = (count) => (h - (count / maxCount) * h) + paddingTop
|
||||
|
||||
// X Axis
|
||||
ctx.font = `${fontSize}px ${fontFamily}`
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.textAlign = 'center'
|
||||
if (min != null && max != null) {
|
||||
const stepsizeX = getStepSize(max - min, w, 75)
|
||||
let startX = 0
|
||||
while (startX < min)
|
||||
startX += stepsizeX
|
||||
|
||||
for (let x = startX; x < max; x += stepsizeX) {
|
||||
let px = ((x - min) / (max - min)) * (w - barWidth) + paddingLeft + (barWidth / 2.)
|
||||
ctx.fillText(`${formatNumber(x)}`, px, height - paddingBottom + 15)
|
||||
}
|
||||
} else {
|
||||
const stepsizeX = getStepSize(maxValue, w, 120)
|
||||
for (let x = 0; x <= maxValue; x += stepsizeX) {
|
||||
ctx.fillText(label(x), getCanvasX(x), height - paddingBottom + 15)
|
||||
}
|
||||
}
|
||||
|
||||
// Y Axis
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.strokeStyle = '#bbbbbb'
|
||||
ctx.textAlign = 'right'
|
||||
ctx.beginPath()
|
||||
const stepsizeY = getStepSize(maxCount, h, 50)
|
||||
for (let y = stepsizeY; y <= maxCount; y += stepsizeY) {
|
||||
const py = Math.floor(getCanvasY(y))
|
||||
ctx.fillText(`${formatNumber(y)}`, paddingLeft - 5, py)
|
||||
ctx.moveTo(paddingLeft, py)
|
||||
ctx.lineTo(width, py)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Draw bars
|
||||
ctx.fillStyle = '#0066cc'
|
||||
for (let p of data) {
|
||||
ctx.fillRect(
|
||||
getCanvasX(p.value) - (barWidth / 2.),
|
||||
getCanvasY(p.count),
|
||||
barWidth,
|
||||
(p.count / maxCount) * h)
|
||||
}
|
||||
|
||||
// Fat lines left and below plotting area
|
||||
ctx.strokeStyle = 'black'
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, height - paddingBottom)
|
||||
ctx.lineTo(width, height - paddingBottom)
|
||||
ctx.moveTo(paddingLeft, 0)
|
||||
ctx.lineTo(paddingLeft, height- paddingBottom)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
let mounted = false
|
||||
onMount(() => {
|
||||
mounted = true
|
||||
canvasElement.width = width
|
||||
canvasElement.height = height
|
||||
ctx = canvasElement.getContext('2d')
|
||||
render()
|
||||
})
|
||||
|
||||
let timeoutId = null;
|
||||
function sizeChanged() {
|
||||
if (timeoutId != null)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null
|
||||
if (!canvasElement)
|
||||
return
|
||||
|
||||
canvasElement.width = width
|
||||
canvasElement.height = height
|
||||
ctx = canvasElement.getContext('2d')
|
||||
render()
|
||||
}, 250)
|
||||
}
|
||||
|
||||
$: sizeChanged(width, height)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
div {
|
||||
position: relative;
|
||||
}
|
||||
div > span {
|
||||
position: absolute;
|
||||
top: 0px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script context="module">
|
||||
import { formatNumber } from '../utils.js'
|
||||
|
||||
export function binsFromFootprint(weights, values, numBins) {
|
||||
let min = 0, max = 0
|
||||
if (values.length != 0) {
|
||||
for (let x of values) {
|
||||
min = Math.min(min, x)
|
||||
max = Math.max(max, x)
|
||||
}
|
||||
max += 1 // So that we have an exclusive range.
|
||||
}
|
||||
|
||||
if (numBins == null || numBins < 3)
|
||||
numBins = 3
|
||||
|
||||
const bins = new Array(numBins).fill(0)
|
||||
for (let i = 0; i < values.length; i++)
|
||||
bins[Math.floor(((values[i] - min) / (max - min)) * numBins)] += weights ? weights[i] : 1
|
||||
|
||||
return {
|
||||
label: idx => {
|
||||
let start = min + (idx / numBins) * (max - min)
|
||||
let stop = min + ((idx + 1) / numBins) * (max - min)
|
||||
return `${formatNumber(start)} - ${formatNumber(stop)}`
|
||||
},
|
||||
bins: bins.map((count, idx) => ({ value: idx, count: count })),
|
||||
min: min,
|
||||
max: max
|
||||
}
|
||||
}
|
||||
</script>
|
306
web/frontend/src/plots/MetricPlot.svelte
Normal file
306
web/frontend/src/plots/MetricPlot.svelte
Normal file
@@ -0,0 +1,306 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Only width/height should change reactively.
|
||||
|
||||
Properties:
|
||||
- width: Number
|
||||
- height: Number
|
||||
- timestep: Number
|
||||
- series: [GraphQL.Series]
|
||||
- statisticsSeries: [GraphQL.StatisticsSeries]
|
||||
- cluster: GraphQL.Cluster
|
||||
- subCluster: String
|
||||
- metric: String
|
||||
- scope: String
|
||||
- useStatsSeries: Boolean
|
||||
|
||||
Functions:
|
||||
- setTimeRange(from, to): Void
|
||||
|
||||
// TODO: Move helper functions to module context?
|
||||
-->
|
||||
<script>
|
||||
import uPlot from 'uplot'
|
||||
import { formatNumber } from '../utils.js'
|
||||
import { getContext, onMount, onDestroy } from 'svelte'
|
||||
|
||||
export let width
|
||||
export let height
|
||||
export let timestep
|
||||
export let series
|
||||
export let statisticsSeries = null
|
||||
export let cluster
|
||||
export let subCluster
|
||||
export let metric
|
||||
export let useStatsSeries = null
|
||||
export let scope = 'node'
|
||||
|
||||
if (useStatsSeries == null)
|
||||
useStatsSeries = statisticsSeries != null
|
||||
|
||||
if (useStatsSeries == false && series == null)
|
||||
useStatsSeries = true
|
||||
|
||||
const metricConfig = getContext('metrics')(cluster, metric)
|
||||
const clusterCockpitConfig = getContext('cc-config')
|
||||
const resizeSleepTime = 250
|
||||
const normalLineColor = '#000000'
|
||||
const lineWidth = clusterCockpitConfig.plot_general_lineWidth / window.devicePixelRatio
|
||||
const lineColors = clusterCockpitConfig.plot_general_colorscheme
|
||||
const backgroundColors = { normal: 'rgba(255, 255, 255, 1.0)', caution: 'rgba(255, 128, 0, 0.3)', alert: 'rgba(255, 0, 0, 0.3)' }
|
||||
const thresholds = findThresholds(metricConfig, scope, typeof subCluster == 'string' ? cluster.subClusters.find(sc => sc.name == subCluster) : subCluster)
|
||||
|
||||
function backgroundColor() {
|
||||
if (clusterCockpitConfig.plot_general_colorBackground == false
|
||||
|| !thresholds
|
||||
|| !(series && series.every(s => s.statistics != null)))
|
||||
return backgroundColors.normal
|
||||
|
||||
let cond = thresholds.alert < thresholds.caution
|
||||
? (a, b) => a <= b
|
||||
: (a, b) => a >= b
|
||||
|
||||
let avg = series.reduce((sum, series) => sum + series.statistics.avg, 0) / series.length
|
||||
|
||||
if (Number.isNaN(avg))
|
||||
return backgroundColors.normal
|
||||
|
||||
if (cond(avg, thresholds.alert))
|
||||
return backgroundColors.alert
|
||||
|
||||
if (cond(avg, thresholds.caution))
|
||||
return backgroundColors.caution
|
||||
|
||||
return backgroundColors.normal
|
||||
}
|
||||
|
||||
function lineColor(i, n) {
|
||||
if (n >= lineColors.length)
|
||||
return lineColors[i % lineColors.length];
|
||||
else
|
||||
return lineColors[Math.floor((i / n) * lineColors.length)];
|
||||
}
|
||||
|
||||
const longestSeries = useStatsSeries
|
||||
? statisticsSeries.mean.length
|
||||
: series.reduce((n, series) => Math.max(n, series.data.length), 0)
|
||||
const maxX = longestSeries * timestep
|
||||
const maxY = thresholds != null
|
||||
? useStatsSeries
|
||||
? (statisticsSeries.max.reduce((max, x) => Math.max(max, x), thresholds.normal) || thresholds.normal)
|
||||
: (series.reduce((max, series) => Math.max(max, series.statistics?.max), thresholds.normal) || thresholds.normal)
|
||||
: null
|
||||
const plotSeries = [{}]
|
||||
const plotData = [new Array(longestSeries)]
|
||||
for (let i = 0; i < longestSeries; i++) // TODO: Cache/Reuse this array?
|
||||
plotData[0][i] = i * timestep
|
||||
|
||||
let plotBands = undefined
|
||||
if (useStatsSeries) {
|
||||
plotData.push(statisticsSeries.min)
|
||||
plotData.push(statisticsSeries.max)
|
||||
plotData.push(statisticsSeries.mean)
|
||||
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'red' })
|
||||
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'green' })
|
||||
plotSeries.push({ scale: 'y', width: lineWidth, stroke: 'black' })
|
||||
plotBands = [
|
||||
{ series: [2,3], fill: 'rgba(0,255,0,0.1)' },
|
||||
{ series: [3,1], fill: 'rgba(255,0,0,0.1)' }
|
||||
];
|
||||
} else {
|
||||
for (let i = 0; i < series.length; i++) {
|
||||
plotData.push(series[i].data)
|
||||
plotSeries.push({
|
||||
scale: 'y',
|
||||
width: lineWidth,
|
||||
stroke: lineColor(i, series.length)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const opts = {
|
||||
width,
|
||||
height,
|
||||
series: plotSeries,
|
||||
axes: [
|
||||
{
|
||||
scale: 'x',
|
||||
space: 35,
|
||||
incrs: timeIncrs(timestep, maxX),
|
||||
values: (_, vals) => vals.map(v => formatTime(v))
|
||||
},
|
||||
{
|
||||
scale: 'y',
|
||||
grid: { show: true },
|
||||
labelFont: 'sans-serif',
|
||||
values: (u, vals) => vals.map(v => formatNumber(v))
|
||||
}
|
||||
],
|
||||
bands: plotBands,
|
||||
padding: [5, 10, -20, 0],
|
||||
hooks: {
|
||||
draw: [(u) => {
|
||||
// Draw plot type label:
|
||||
let text = `${scope}${plotSeries.length > 2 ? 's' : ''}${useStatsSeries ? ': min/avg/max' : ''}`
|
||||
u.ctx.save()
|
||||
u.ctx.textAlign = 'start' // 'end'
|
||||
u.ctx.fillStyle = 'black'
|
||||
u.ctx.fillText(text, u.bbox.left + 10, u.bbox.top + 10)
|
||||
// u.ctx.fillText(text, u.bbox.left + u.bbox.width - 10, u.bbox.top + u.bbox.height - 10)
|
||||
|
||||
if (!thresholds) {
|
||||
u.ctx.restore()
|
||||
return
|
||||
}
|
||||
|
||||
let y = u.valToPos(thresholds.normal, 'y', true)
|
||||
u.ctx.save()
|
||||
u.ctx.lineWidth = lineWidth
|
||||
u.ctx.strokeStyle = normalLineColor
|
||||
u.ctx.setLineDash([5, 5])
|
||||
u.ctx.beginPath()
|
||||
u.ctx.moveTo(u.bbox.left, y)
|
||||
u.ctx.lineTo(u.bbox.left + u.bbox.width, y)
|
||||
u.ctx.stroke()
|
||||
u.ctx.restore()
|
||||
}]
|
||||
},
|
||||
scales: {
|
||||
x: { time: false },
|
||||
y: maxY ? { range: [0., maxY * 1.1] } : {}
|
||||
},
|
||||
cursor: { show: false },
|
||||
legend: { show: false, live: false }
|
||||
}
|
||||
|
||||
// console.log(opts)
|
||||
|
||||
let plotWrapper = null
|
||||
let uplot = null
|
||||
let timeoutId = null
|
||||
let prevWidth = null, prevHeight = null
|
||||
|
||||
function render() {
|
||||
if (!width || Number.isNaN(width) || width < 0)
|
||||
return
|
||||
|
||||
if (prevWidth != null && Math.abs(prevWidth - width) < 10)
|
||||
return
|
||||
|
||||
prevWidth = width
|
||||
prevHeight = height
|
||||
|
||||
if (!uplot) {
|
||||
opts.width = width
|
||||
opts.height = height
|
||||
uplot = new uPlot(opts, plotData, plotWrapper)
|
||||
} else {
|
||||
uplot.setSize({ width, height })
|
||||
}
|
||||
}
|
||||
|
||||
function onSizeChange() {
|
||||
if (!uplot)
|
||||
return
|
||||
|
||||
if (timeoutId != null)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null
|
||||
render()
|
||||
}, resizeSleepTime)
|
||||
}
|
||||
|
||||
$: onSizeChange(width, height)
|
||||
|
||||
onMount(() => {
|
||||
plotWrapper.style.backgroundColor = backgroundColor()
|
||||
render()
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (uplot)
|
||||
uplot.destroy()
|
||||
|
||||
if (timeoutId != null)
|
||||
clearTimeout(timeoutId)
|
||||
})
|
||||
|
||||
// `from` and `to` must be numbers between 0 and 1.
|
||||
export function setTimeRange(from, to) {
|
||||
if (!uplot || from > to)
|
||||
return false
|
||||
|
||||
uplot.setScale('x', { min: from * maxX, max: to * maxX })
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
<script context="module">
|
||||
|
||||
export function formatTime(t) {
|
||||
let h = Math.floor(t / 3600)
|
||||
let m = Math.floor((t % 3600) / 60)
|
||||
if (h == 0)
|
||||
return `${m}m`
|
||||
else if (m == 0)
|
||||
return `${h}h`
|
||||
else
|
||||
return `${h}:${m}h`
|
||||
}
|
||||
|
||||
export function timeIncrs(timestep, maxX) {
|
||||
let incrs = []
|
||||
for (let t = timestep; t < maxX; t *= 10)
|
||||
incrs.push(t, t * 2, t * 3, t * 5)
|
||||
|
||||
return incrs
|
||||
}
|
||||
|
||||
export function findThresholds(metricConfig, scope, subCluster) {
|
||||
if (!metricConfig || !scope || !subCluster)
|
||||
return null
|
||||
|
||||
if (scope == 'node' || metricConfig.aggregation == 'avg') {
|
||||
if (!metricConfig.subClusters)
|
||||
return { normal: metricConfig.normal, caution: metricConfig.caution, alert: metricConfig.alert }
|
||||
else
|
||||
return metricConfig.subClusters.find(sc => sc.name == subCluster.name)
|
||||
}
|
||||
|
||||
if (metricConfig.aggregation != 'sum') {
|
||||
console.warn('Missing or unkown aggregation mode (sum/avg) for metric:', metricConfig)
|
||||
return null
|
||||
}
|
||||
|
||||
let divisor = 1
|
||||
if (scope == 'socket')
|
||||
divisor = subCluster.topology.socket.length
|
||||
else if (scope == 'core')
|
||||
divisor = subCluster.topology.core.length
|
||||
else if (scope == 'accelerator')
|
||||
divisor = subCluster.topology.accelerators.length
|
||||
else if (scope == 'hwthread')
|
||||
divisor = subCluster.topology.node.length
|
||||
else {
|
||||
console.log('TODO: how to calc thresholds for ', scope)
|
||||
return null
|
||||
}
|
||||
|
||||
let mc = metricConfig?.subClusters?.find(sc => sc.name == subCluster.name) || metricConfig
|
||||
return {
|
||||
normal: mc.normal / divisor,
|
||||
caution: mc.caution / divisor,
|
||||
alert: mc.alert / divisor
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div bind:this={plotWrapper} class="cc-plot"></div>
|
||||
<style>
|
||||
.cc-plot {
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
190
web/frontend/src/plots/Polar.svelte
Normal file
190
web/frontend/src/plots/Polar.svelte
Normal file
@@ -0,0 +1,190 @@
|
||||
<div>
|
||||
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import { onMount, getContext } from 'svelte'
|
||||
|
||||
export let metrics
|
||||
export let width
|
||||
export let height
|
||||
export let cluster
|
||||
export let jobMetrics
|
||||
|
||||
const fontSize = 12
|
||||
const fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
||||
const metricConfig = getContext('metrics')
|
||||
|
||||
let ctx, canvasElement
|
||||
|
||||
const labels = metrics.filter(name => {
|
||||
if (!jobMetrics.find(m => m.name == name && m.metric.scope == "node")) {
|
||||
console.warn(`PolarPlot: No metric data for '${name}'`)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const getValuesForStat = (getStat) => labels.map(name => {
|
||||
const peak = metricConfig(cluster, name).peak
|
||||
const metric = jobMetrics.find(m => m.name == name && m.metric.scope == "node")
|
||||
const value = getStat(metric.metric) / peak
|
||||
return value <= 1. ? value : 1.
|
||||
})
|
||||
|
||||
function getMax(metric) {
|
||||
let max = 0
|
||||
for (let series of metric.series)
|
||||
max = Math.max(max, series.statistics.max)
|
||||
return max
|
||||
}
|
||||
|
||||
function getAvg(metric) {
|
||||
let avg = 0
|
||||
for (let series of metric.series)
|
||||
avg += series.statistics.avg
|
||||
return avg / metric.series.length
|
||||
}
|
||||
|
||||
const data = [
|
||||
{
|
||||
name: 'Max',
|
||||
values: getValuesForStat(getMax),
|
||||
color: 'rgb(0, 102, 255)',
|
||||
areaColor: 'rgba(0, 102, 255, 0.25)'
|
||||
},
|
||||
{
|
||||
name: 'Avg',
|
||||
values: getValuesForStat(getAvg),
|
||||
color: 'rgb(255, 153, 0)',
|
||||
areaColor: 'rgba(255, 153, 0, 0.25)'
|
||||
}
|
||||
]
|
||||
|
||||
function render() {
|
||||
if (!width || Number.isNaN(width))
|
||||
return
|
||||
|
||||
const centerX = width / 2
|
||||
const centerY = height / 2 - 15
|
||||
const radius = (Math.min(width, height) / 2) - 50
|
||||
|
||||
// Draw circles
|
||||
ctx.lineWidth = 1
|
||||
ctx.strokeStyle = '#999999'
|
||||
ctx.beginPath()
|
||||
ctx.arc(centerX, centerY, radius * 1.0, 0, Math.PI * 2, false)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.arc(centerX, centerY, radius * 0.666, 0, Math.PI * 2, false)
|
||||
ctx.stroke()
|
||||
ctx.beginPath()
|
||||
ctx.arc(centerX, centerY, radius * 0.333, 0, Math.PI * 2, false)
|
||||
ctx.stroke()
|
||||
|
||||
// Axis
|
||||
ctx.font = `${fontSize}px ${fontFamily}`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('1/3',
|
||||
Math.floor(centerX + radius * 0.333),
|
||||
Math.floor(centerY + 15))
|
||||
ctx.fillText('2/3',
|
||||
Math.floor(centerX + radius * 0.666),
|
||||
Math.floor(centerY + 15))
|
||||
ctx.fillText('1.0',
|
||||
Math.floor(centerX + radius * 1.0),
|
||||
Math.floor(centerY + 15))
|
||||
|
||||
// Label text and straight lines from center
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
const angle = 2 * Math.PI * ((i + 1) / labels.length)
|
||||
const dx = Math.cos(angle) * radius
|
||||
const dy = Math.sin(angle) * radius
|
||||
ctx.fillText(labels[i],
|
||||
Math.floor(centerX + dx * 1.1),
|
||||
Math.floor(centerY + dy * 1.1))
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(centerX, centerY)
|
||||
ctx.lineTo(centerX + dx, centerY + dy)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
for (let dataset of data) {
|
||||
console.assert(dataset.values.length === labels.length, 'this will look confusing')
|
||||
ctx.fillStyle = dataset.color
|
||||
ctx.strokeStyle = dataset.color
|
||||
const points = []
|
||||
for (let i = 0; i < dataset.values.length; i++) {
|
||||
const value = dataset.values[i]
|
||||
const angle = 2 * Math.PI * ((i + 1) / labels.length)
|
||||
const x = centerX + Math.cos(angle) * radius * value
|
||||
const y = centerY + Math.sin(angle) * radius * value
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 3, 0, Math.PI * 2, false)
|
||||
ctx.fill()
|
||||
|
||||
points.push({ x, y })
|
||||
}
|
||||
|
||||
// "Fill" the shape this dataset has
|
||||
ctx.fillStyle = dataset.areaColor
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(points[0].x, points[0].y)
|
||||
for (let p of points)
|
||||
ctx.lineTo(p.x, p.y)
|
||||
ctx.lineTo(points[0].x, points[0].y)
|
||||
ctx.stroke()
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Legend at the bottom left corner
|
||||
ctx.textAlign = 'left'
|
||||
let paddingLeft = 0
|
||||
for (let dataset of data) {
|
||||
const text = `${dataset.name}: `
|
||||
const textWidth = ctx.measureText(text).width
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.fillText(text, paddingLeft, height - 20)
|
||||
|
||||
ctx.fillStyle = dataset.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(paddingLeft + textWidth + 5, height - 25, 5, 0, Math.PI * 2, false)
|
||||
ctx.fill()
|
||||
|
||||
paddingLeft += textWidth + 15
|
||||
}
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.fillText(`Values relative to respective peak.`, 0, height - 7)
|
||||
}
|
||||
|
||||
let mounted = false
|
||||
onMount(() => {
|
||||
canvasElement.width = width
|
||||
canvasElement.height = height
|
||||
ctx = canvasElement.getContext('2d')
|
||||
render(ctx, data, width, height)
|
||||
mounted = true
|
||||
})
|
||||
|
||||
let timeoutId = null
|
||||
function sizeChanged() {
|
||||
if (!mounted)
|
||||
return;
|
||||
|
||||
if (timeoutId != null)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null
|
||||
|
||||
canvasElement.width = width
|
||||
canvasElement.height = height
|
||||
ctx = canvasElement.getContext('2d')
|
||||
render(ctx, data, width, height)
|
||||
}, 250)
|
||||
}
|
||||
|
||||
$: sizeChanged(width, height)
|
||||
</script>
|
355
web/frontend/src/plots/Roofline.svelte
Normal file
355
web/frontend/src/plots/Roofline.svelte
Normal file
@@ -0,0 +1,355 @@
|
||||
<div class="cc-plot">
|
||||
<canvas bind:this={canvasElement} width="{prevWidth}" height="{prevHeight}"></canvas>
|
||||
</div>
|
||||
|
||||
<script context="module">
|
||||
const axesColor = '#aaaaaa'
|
||||
const fontSize = 12
|
||||
const fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
||||
const paddingLeft = 40,
|
||||
paddingRight = 10,
|
||||
paddingTop = 10,
|
||||
paddingBottom = 50
|
||||
|
||||
function getGradientR(x) {
|
||||
if (x < 0.5) return 0
|
||||
if (x > 0.75) return 255
|
||||
x = (x - 0.5) * 4.0
|
||||
return Math.floor(x * 255.0)
|
||||
}
|
||||
|
||||
function getGradientG(x) {
|
||||
if (x > 0.25 && x < 0.75) return 255
|
||||
if (x < 0.25) x = x * 4.0
|
||||
else x = 1.0 - (x - 0.75) * 4.0
|
||||
return Math.floor(x * 255.0)
|
||||
}
|
||||
|
||||
function getGradientB(x) {
|
||||
if (x < 0.25) return 255
|
||||
if (x > 0.5) return 0
|
||||
x = 1.0 - (x - 0.25) * 4.0
|
||||
return Math.floor(x * 255.0)
|
||||
}
|
||||
|
||||
function getRGB(c) {
|
||||
return `rgb(${getGradientR(c)}, ${getGradientG(c)}, ${getGradientB(c)})`
|
||||
}
|
||||
|
||||
function lineIntersect(x1, y1, x2, y2, x3, y3, x4, y4) {
|
||||
let l = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
|
||||
let a = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / l
|
||||
return {
|
||||
x: x1 + a * (x2 - x1),
|
||||
y: y1 + a * (y2 - y1)
|
||||
}
|
||||
}
|
||||
|
||||
const power = [1, 1e3, 1e6, 1e9, 1e12]
|
||||
const suffix = ['', 'k', 'm', 'g']
|
||||
function formatNumber(x) {
|
||||
for (let i = 0; i < suffix.length; i++)
|
||||
if (power[i] <= x && x < power[i+1])
|
||||
return `${x / power[i]}${suffix[i]}`
|
||||
|
||||
return Math.abs(x) >= 1000 ? x.toExponential() : x.toString()
|
||||
}
|
||||
|
||||
function axisStepFactor(i, size) {
|
||||
if (size && size < 500)
|
||||
return 10
|
||||
|
||||
if (i % 3 == 0)
|
||||
return 2
|
||||
else if (i % 3 == 1)
|
||||
return 2.5
|
||||
else
|
||||
return 2
|
||||
}
|
||||
|
||||
function render(ctx, data, cluster, width, height, colorDots, defaultMaxY) {
|
||||
if (width <= 0)
|
||||
return
|
||||
|
||||
const [minX, maxX, minY, maxY] = [0.01, 1000, 1., cluster?.flopRateSimd || defaultMaxY]
|
||||
const w = width - paddingLeft - paddingRight
|
||||
const h = height - paddingTop - paddingBottom
|
||||
|
||||
// Helpers:
|
||||
const [log10minX, log10maxX, log10minY, log10maxY] =
|
||||
[Math.log10(minX), Math.log10(maxX), Math.log10(minY), Math.log10(maxY)]
|
||||
|
||||
/* Value -> Pixel-Coordinate */
|
||||
const getCanvasX = (x) => {
|
||||
x = Math.log10(x)
|
||||
x -= log10minX; x /= (log10maxX - log10minX)
|
||||
return Math.round((x * w) + paddingLeft)
|
||||
}
|
||||
const getCanvasY = (y) => {
|
||||
y = Math.log10(y)
|
||||
y -= log10minY
|
||||
y /= (log10maxY - log10minY)
|
||||
return Math.round((h - y * h) + paddingTop)
|
||||
}
|
||||
|
||||
// Axes
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.strokeStyle = axesColor
|
||||
ctx.font = `${fontSize}px ${fontFamily}`
|
||||
ctx.beginPath()
|
||||
for (let x = minX, i = 0; x <= maxX; i++) {
|
||||
let px = getCanvasX(x)
|
||||
let text = formatNumber(x)
|
||||
let textWidth = ctx.measureText(text).width
|
||||
ctx.fillText(text,
|
||||
Math.floor(px - (textWidth / 2)),
|
||||
height - paddingBottom + fontSize + 5)
|
||||
ctx.moveTo(px, paddingTop - 5)
|
||||
ctx.lineTo(px, height - paddingBottom + 5)
|
||||
|
||||
x *= axisStepFactor(i, w)
|
||||
}
|
||||
if (data.xLabel) {
|
||||
let textWidth = ctx.measureText(data.xLabel).width
|
||||
ctx.fillText(data.xLabel, Math.floor((width / 2) - (textWidth / 2)), height - 20)
|
||||
}
|
||||
|
||||
ctx.textAlign = 'center'
|
||||
for (let y = minY, i = 0; y <= maxY; i++) {
|
||||
let py = getCanvasY(y)
|
||||
ctx.moveTo(paddingLeft - 5, py)
|
||||
ctx.lineTo(width - paddingRight + 5, py)
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(paddingLeft - 10, py)
|
||||
ctx.rotate(-Math.PI / 2)
|
||||
ctx.fillText(formatNumber(y), 0, 0)
|
||||
ctx.restore()
|
||||
|
||||
y *= axisStepFactor(i)
|
||||
}
|
||||
if (data.yLabel) {
|
||||
ctx.save()
|
||||
ctx.translate(15, Math.floor(height / 2))
|
||||
ctx.rotate(-Math.PI / 2)
|
||||
ctx.fillText(data.yLabel, 0, 0)
|
||||
ctx.restore()
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
// Draw Data
|
||||
if (data.x && data.y) {
|
||||
for (let i = 0; i < data.x.length; i++) {
|
||||
let x = data.x[i], y = data.y[i], c = data.c[i]
|
||||
if (x == null || y == null || Number.isNaN(x) || Number.isNaN(y))
|
||||
continue
|
||||
|
||||
const s = 3
|
||||
const px = getCanvasX(x)
|
||||
const py = getCanvasY(y)
|
||||
|
||||
ctx.fillStyle = getRGB(c)
|
||||
ctx.beginPath()
|
||||
ctx.arc(px, py, s, 0, Math.PI * 2, false)
|
||||
ctx.fill()
|
||||
}
|
||||
} else if (data.tiles) {
|
||||
const rows = data.tiles.length
|
||||
const cols = data.tiles[0].length
|
||||
|
||||
const tileWidth = Math.ceil(w / cols)
|
||||
const tileHeight = Math.ceil(h / rows)
|
||||
|
||||
let max = data.tiles.reduce((max, row) =>
|
||||
Math.max(max, row.reduce((max, val) =>
|
||||
Math.max(max, val)), 0), 0)
|
||||
|
||||
if (max == 0)
|
||||
max = 1
|
||||
|
||||
const tileColor = val => `rgba(255, 0, 0, ${(val / max)})`
|
||||
|
||||
for (let i = 0; i < rows; i++) {
|
||||
for (let j = 0; j < cols; j++) {
|
||||
let px = paddingLeft + (j / cols) * w
|
||||
let py = paddingTop + (h - (i / rows) * h) - tileHeight
|
||||
|
||||
ctx.fillStyle = tileColor(data.tiles[i][j])
|
||||
ctx.fillRect(px, py, tileWidth, tileHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw roofs
|
||||
ctx.strokeStyle = 'black'
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
if (cluster != null) {
|
||||
const ycut = 0.01 * cluster.memoryBandwidth
|
||||
const scalarKnee = (cluster.flopRateScalar - ycut) / cluster.memoryBandwidth
|
||||
const simdKnee = (cluster.flopRateSimd - ycut) / cluster.memoryBandwidth
|
||||
const scalarKneeX = getCanvasX(scalarKnee),
|
||||
simdKneeX = getCanvasX(simdKnee),
|
||||
flopRateScalarY = getCanvasY(cluster.flopRateScalar),
|
||||
flopRateSimdY = getCanvasY(cluster.flopRateSimd)
|
||||
|
||||
if (scalarKneeX < width - paddingRight) {
|
||||
ctx.moveTo(scalarKneeX, flopRateScalarY)
|
||||
ctx.lineTo(width - paddingRight, flopRateScalarY)
|
||||
}
|
||||
|
||||
if (simdKneeX < width - paddingRight) {
|
||||
ctx.moveTo(simdKneeX, flopRateSimdY)
|
||||
ctx.lineTo(width - paddingRight, flopRateSimdY)
|
||||
}
|
||||
|
||||
let x1 = getCanvasX(0.01),
|
||||
y1 = getCanvasY(ycut),
|
||||
x2 = getCanvasX(simdKnee),
|
||||
y2 = flopRateSimdY
|
||||
|
||||
let xAxisIntersect = lineIntersect(
|
||||
x1, y1, x2, y2,
|
||||
0, height - paddingBottom, width, height - paddingBottom)
|
||||
|
||||
if (xAxisIntersect.x > x1) {
|
||||
x1 = xAxisIntersect.x
|
||||
y1 = xAxisIntersect.y
|
||||
}
|
||||
|
||||
ctx.moveTo(x1, y1)
|
||||
ctx.lineTo(x2, y2)
|
||||
}
|
||||
ctx.stroke()
|
||||
|
||||
if (colorDots && data.x && data.y) {
|
||||
// The Color Scale
|
||||
ctx.fillStyle = 'black'
|
||||
ctx.fillText('Time:', 17, height - 5)
|
||||
const start = paddingLeft + 5
|
||||
for (let x = start; x < width - paddingRight; x += 15) {
|
||||
let c = (x - start) / (width - start - paddingRight)
|
||||
ctx.fillStyle = getRGB(c)
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, height - 10, 5, 0, Math.PI * 2, false)
|
||||
ctx.fill()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function transformData(flopsAny, memBw, colorDots) {
|
||||
const nodes = flopsAny.series.length
|
||||
const timesteps = flopsAny.series[0].data.length
|
||||
|
||||
/* c will contain values from 0 to 1 representing the time */
|
||||
const x = [], y = [], c = []
|
||||
for (let i = 0; i < nodes; i++) {
|
||||
const flopsData = flopsAny.series[i].data
|
||||
const memBwData = memBw.series[i].data
|
||||
for (let j = 0; j < timesteps; j++) {
|
||||
const f = flopsData[j], m = memBwData[j]
|
||||
const intensity = f / m
|
||||
if (Number.isNaN(intensity) || !Number.isFinite(intensity))
|
||||
continue
|
||||
|
||||
x.push(intensity)
|
||||
y.push(f)
|
||||
c.push(colorDots ? j / timesteps : 0)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
x, y, c,
|
||||
xLabel: 'Intensity [FLOPS/byte]',
|
||||
yLabel: 'Performance [GFLOPS]'
|
||||
}
|
||||
}
|
||||
|
||||
// Return something to be plotted. The argument shall be the result of the
|
||||
// `nodeMetrics` GraphQL query.
|
||||
export function transformPerNodeData(nodes) {
|
||||
const x = [], y = [], c = []
|
||||
for (let node of nodes) {
|
||||
let flopsAny = node.metrics.find(m => m.name == 'flops_any' && m.metric.scope == 'node')?.metric
|
||||
let memBw = node.metrics.find(m => m.name == 'mem_bw' && m.metric.scope == 'node')?.metric
|
||||
if (!flopsAny || !memBw)
|
||||
continue
|
||||
|
||||
let flopsData = flopsAny.series[0].data, memBwData = memBw.series[0].data
|
||||
const f = flopsData[flopsData.length - 1], m = memBwData[flopsData.length - 1]
|
||||
const intensity = f / m
|
||||
if (Number.isNaN(intensity) || !Number.isFinite(intensity))
|
||||
continue
|
||||
|
||||
x.push(intensity)
|
||||
y.push(f)
|
||||
c.push(0)
|
||||
}
|
||||
|
||||
return {
|
||||
x, y, c,
|
||||
xLabel: 'Intensity [FLOPS/byte]',
|
||||
yLabel: 'Performance [GFLOPS]'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { onMount, tick } from 'svelte'
|
||||
|
||||
export let flopsAny = null
|
||||
export let memBw = null
|
||||
export let cluster = null
|
||||
export let maxY = null
|
||||
export let width
|
||||
export let height
|
||||
export let tiles = null
|
||||
export let colorDots = true
|
||||
export let data = null
|
||||
|
||||
console.assert(data || tiles || (flopsAny && memBw), "you must provide flopsAny and memBw or tiles!")
|
||||
|
||||
let ctx, canvasElement, prevWidth = width, prevHeight = height
|
||||
data = data != null ? data : (flopsAny && memBw
|
||||
? transformData(flopsAny, memBw, colorDots)
|
||||
: {
|
||||
tiles: tiles,
|
||||
xLabel: 'Intensity [FLOPS/byte]',
|
||||
yLabel: 'Performance [GFLOPS]'
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
ctx = canvasElement.getContext('2d')
|
||||
if (prevWidth != width || prevHeight != height) {
|
||||
sizeChanged()
|
||||
return
|
||||
}
|
||||
|
||||
canvasElement.width = width
|
||||
canvasElement.height = height
|
||||
render(ctx, data, cluster, width, height, colorDots, maxY)
|
||||
})
|
||||
|
||||
let timeoutId = null
|
||||
function sizeChanged() {
|
||||
if (!ctx)
|
||||
return
|
||||
|
||||
if (timeoutId != null)
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
prevWidth = width
|
||||
prevHeight = height
|
||||
timeoutId = setTimeout(() => {
|
||||
if (!canvasElement)
|
||||
return
|
||||
|
||||
timeoutId = null
|
||||
canvasElement.width = width
|
||||
canvasElement.height = height
|
||||
render(ctx, data, cluster, width, height, colorDots, maxY)
|
||||
}, 250)
|
||||
}
|
||||
|
||||
$: sizeChanged(width, height)
|
||||
</script>
|
171
web/frontend/src/plots/Scatter.svelte
Normal file
171
web/frontend/src/plots/Scatter.svelte
Normal file
@@ -0,0 +1,171 @@
|
||||
<div class="cc-plot">
|
||||
<canvas bind:this={canvasElement} width="{width}" height="{height}"></canvas>
|
||||
</div>
|
||||
|
||||
<script context="module">
|
||||
import { formatNumber } from '../utils.js'
|
||||
|
||||
const axesColor = '#aaaaaa'
|
||||
const fontSize = 12
|
||||
const fontFamily = 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"'
|
||||
const paddingLeft = 40,
|
||||
paddingRight = 10,
|
||||
paddingTop = 10,
|
||||
paddingBottom = 50
|
||||
|
||||
function getStepSize(valueRange, pixelRange, minSpace) {
|
||||
const proposition = valueRange / (pixelRange / minSpace);
|
||||
const getStepSize = n => Math.pow(10, Math.floor(n / 3)) *
|
||||
(n < 0 ? [1., 5., 2.][-n % 3] : [1., 2., 5.][n % 3]);
|
||||
|
||||
let n = 0;
|
||||
let stepsize = getStepSize(n);
|
||||
while (true) {
|
||||
let bigger = getStepSize(n + 1);
|
||||
if (proposition > bigger) {
|
||||
n += 1;
|
||||
stepsize = bigger;
|
||||
} else {
|
||||
return stepsize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function render(ctx, X, Y, S, color, xLabel, yLabel, width, height) {
|
||||
if (width <= 0)
|
||||
return;
|
||||
|
||||
const [minX, minY] = [0., 0.];
|
||||
let maxX = X.reduce((maxX, x) => Math.max(maxX, x), minX);
|
||||
let maxY = Y.reduce((maxY, y) => Math.max(maxY, y), minY);
|
||||
const w = width - paddingLeft - paddingRight;
|
||||
const h = height - paddingTop - paddingBottom;
|
||||
|
||||
if (maxX == 0 && maxY == 0) {
|
||||
maxX = 1;
|
||||
maxY = 1;
|
||||
}
|
||||
|
||||
/* Value -> Pixel-Coordinate */
|
||||
const getCanvasX = (x) => {
|
||||
x -= minX; x /= (maxX - minX);
|
||||
return Math.round((x * w) + paddingLeft);
|
||||
};
|
||||
const getCanvasY = (y) => {
|
||||
y -= minY; y /= (maxY - minY);
|
||||
return Math.round((h - y * h) + paddingTop);
|
||||
};
|
||||
|
||||
// Draw Data
|
||||
let size = 3
|
||||
if (S) {
|
||||
let max = S.reduce((max, s, i) => (X[i] == null || Y[i] == null || Number.isNaN(X[i]) || Number.isNaN(Y[i])) ? max : Math.max(max, s), 0)
|
||||
size = (w / 15) / max
|
||||
}
|
||||
|
||||
ctx.fillStyle = color;
|
||||
for (let i = 0; i < X.length; i++) {
|
||||
let x = X[i], y = Y[i];
|
||||
if (x == null || y == null || Number.isNaN(x) || Number.isNaN(y))
|
||||
continue;
|
||||
|
||||
const s = S ? S[i] * size : size;
|
||||
const px = getCanvasX(x);
|
||||
const py = getCanvasY(y);
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, s, 0, Math.PI * 2, false);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Axes
|
||||
ctx.fillStyle = '#000000'
|
||||
ctx.strokeStyle = axesColor;
|
||||
ctx.font = `${fontSize}px ${fontFamily}`;
|
||||
ctx.beginPath();
|
||||
const stepsizeX = getStepSize(maxX, w, 75);
|
||||
for (let x = minX, i = 0; x <= maxX; i++) {
|
||||
let px = getCanvasX(x);
|
||||
let text = formatNumber(x);
|
||||
let textWidth = ctx.measureText(text).width;
|
||||
ctx.fillText(text,
|
||||
Math.floor(px - (textWidth / 2)),
|
||||
height - paddingBottom + fontSize + 5);
|
||||
ctx.moveTo(px, paddingTop - 5);
|
||||
ctx.lineTo(px, height - paddingBottom + 5);
|
||||
|
||||
x += stepsizeX;
|
||||
}
|
||||
if (xLabel) {
|
||||
let textWidth = ctx.measureText(xLabel).width;
|
||||
ctx.fillText(xLabel, Math.floor((width / 2) - (textWidth / 2)), height - 20);
|
||||
}
|
||||
|
||||
ctx.textAlign = 'center';
|
||||
const stepsizeY = getStepSize(maxY, h, 75);
|
||||
for (let y = minY, i = 0; y <= maxY; i++) {
|
||||
let py = getCanvasY(y);
|
||||
ctx.moveTo(paddingLeft - 5, py);
|
||||
ctx.lineTo(width - paddingRight + 5, py);
|
||||
|
||||
ctx.save();
|
||||
ctx.translate(paddingLeft - 10, py);
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(formatNumber(y), 0, 0);
|
||||
ctx.restore();
|
||||
|
||||
y += stepsizeY;
|
||||
}
|
||||
if (yLabel) {
|
||||
ctx.save();
|
||||
ctx.translate(15, Math.floor(height / 2));
|
||||
ctx.rotate(-Math.PI / 2);
|
||||
ctx.fillText(yLabel, 0, 0);
|
||||
ctx.restore();
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
export let X;
|
||||
export let Y;
|
||||
export let S = null;
|
||||
export let color = '#0066cc';
|
||||
export let width;
|
||||
export let height;
|
||||
export let xLabel;
|
||||
export let yLabel;
|
||||
|
||||
let ctx;
|
||||
let canvasElement;
|
||||
|
||||
onMount(() => {
|
||||
canvasElement.width = width;
|
||||
canvasElement.height = height;
|
||||
ctx = canvasElement.getContext('2d');
|
||||
render(ctx, X, Y, S, color, xLabel, yLabel, width, height);
|
||||
});
|
||||
|
||||
let timeoutId = null;
|
||||
function sizeChanged() {
|
||||
if (timeoutId != null)
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timeoutId = null;
|
||||
if (!canvasElement)
|
||||
return;
|
||||
|
||||
canvasElement.width = width;
|
||||
canvasElement.height = height;
|
||||
ctx = canvasElement.getContext('2d');
|
||||
render(ctx, X, Y, S, color, xLabel, yLabel, width, height);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
$: sizeChanged(width, height);
|
||||
|
||||
</script>
|
Reference in New Issue
Block a user