The Contours Series Type
Contour maps or Contour-plots can be created using the UniformContoursRenderableSeries📘 type.
The JavaScript Heatmap Chart Example can be found in the SciChart.Js Examples Suite > Contours Chart on Github, or our live demo at scichart.com/demo.
Create a Contours Plot
SciChart's Contour series is an extremely fast, lightweight chart types for rendering two dimensional data as a contour plot. The UniformContoursRenderableSeries📘 type should be used in conjunction with a UniformHeatmapDataSeries📘 when you simply want to specify a Step in the X,Y direction (each cell is the same size).
To create a Javascript Contours Chart with SciChart.js, use the following code:
- TS
- Builder API (JSON Config)
// Demonstrates how to create a contour plot with SciChart.js
const {
SciChartSurface,
NumericAxis,
HeatmapColorMap,
UniformHeatmapDataSeries,
UniformHeatmapRenderableSeries,
UniformContoursRenderableSeries,
SciChartJsNavyTheme
} = SciChart;
// or, for npm, import { SciChartSurface, ... } from "scichart"
// Create a SciChartSurface with X & Y Axis
const { wasmContext, sciChartSurface } = await SciChartSurface.create(divElementId, {
theme: new SciChartJsNavyTheme()
});
sciChartSurface.xAxes.add(new NumericAxis(wasmContext));
sciChartSurface.yAxes.add(new NumericAxis(wasmContext));
const WIDTH = 300;
const HEIGHT = 200;
const colorPaletteMax = 200;
// Create a Heatmap Data-series. zValues are heatValues as a 2D Array (number[][])
// Open the Codepen below to see the definition of this function
const zValues = generateExampleData(3, WIDTH, HEIGHT, colorPaletteMax);
// Create the uniform heatmap data series. Pass heatValues as number[][]
const heatmapDataSeries = new UniformHeatmapDataSeries(wasmContext, {
// 2d zValues array. Dimensions [height][width]
zValues,
// xStart, xStep, yStart, yStep defines the x,y position
xStart: 0,
xStep: 1,
yStart: 0,
yStep: 1
});
// Create a Contours RenderableSeries with the same data
const contourSeries = new UniformContoursRenderableSeries(wasmContext, {
dataSeries: heatmapDataSeries,
zMin: 20,
zMax: colorPaletteMax,
zStep: 20
});
// Add it to the scichartsurface
sciChartSurface.renderableSeries.add(contourSeries);
// Create a background heatmap series with the same data and add to the chart
const heatmapSeries = new UniformHeatmapRenderableSeries(wasmContext, {
dataSeries: heatmapDataSeries,
opacity: 0.5,
useLinearTextureFiltering: false,
// See heatmap documentation for description of how colormaps work
colorMap: new HeatmapColorMap({
minimum: 0,
maximum: colorPaletteMax,
gradientStops: [
{ offset: 1, color: "#EC0F6C" },
{ offset: 0.9, color: "#F48420" },
{ offset: 0.7, color: "#DC7969" },
{ offset: 0.5, color: "#67BDAF" },
{ offset: 0.3, color: "#50C7E0" },
{ offset: 0.2, color: "#264B93" },
{ offset: 0, color: "#14233C" }
]
})
});
// Add to the SciChartSurface
sciChartSurface.renderableSeries.add(heatmapSeries);
// Demonstrates how to create a line chart with SciChart.js using the Builder API
const { build2DChart, ESeriesType, HeatmapColorMap, EThemeProviderType } = SciChart;
const WIDTH = 300;
const HEIGHT = 200;
const colorPaletteMax = 200;
// Create a Heatmap Data-series. zValues are heatValues as a 2D Array (number[][])
// Open the Codepen below to see the definition of this function
const zValues = generateExampleData(3, WIDTH, HEIGHT, colorPaletteMax);
const { wasmContext, sciChartSurface } = await build2DChart(divElementId, {
surface: { theme: { type: EThemeProviderType.Dark } },
series: [
{
type: ESeriesType.UniformContoursSeries,
options: {
zMin: 20,
zMax: colorPaletteMax,
zStep: 20
},
heatmapData: {
zValues,
xStart: 0,
xStep: 1,
yStart: 0,
yStep: 1
}
},
{
type: ESeriesType.UniformHeatmapSeries,
options: {
colorMap: new HeatmapColorMap({
minimum: 0,
maximum: colorPaletteMax,
gradientStops: [
{ offset: 1, color: "#EC0F6C" },
{ offset: 0.9, color: "#F48420" },
{ offset: 0.7, color: "#DC7969" },
{ offset: 0.5, color: "#67BDAF" },
{ offset: 0.3, color: "#50C7E0" },
{ offset: 0.2, color: "#264B93" },
{ offset: 0, color: "#14233C" }
]
}),
opacity: 0.5
},
heatmapData: {
zValues,
xStart: 0,
xStep: 1,
yStart: 0,
yStep: 1
}
}
]
});
In the code above:
- We create an empty 2D array
number[][]using the helper functionzeroArray2D. This is filled with values in the generateData function - A UniformHeatmapDataSeries📘 instance is created with
xStart,xStep,yStart,yStepvalues =0,1,0,1. This means the heatmap starts at(X, Y)=(0, 0)and each cell is1on the axis. - We set the contour
strokeandstrokeThickness. - A UniformContoursRenderableSeries📘 instance is created and added to the sciChartSurface.renderableSeries📘 collection.
This results in the following output:
Updating Data in a Contour map
The contour map is supposed to be fully dynamic, enabling real-time graphics. The Contours Series📘 however does not support append, insert, update, remove functions like other DataSeries do. You can however update the data and force a refresh simply by updating the data passed in. To do this, use the following code:
import { UniformHeatmapDataSeries, zeroArray2D } from "scichart";
const height = 10; // Set the height of the heatmap
const width = 20; // Set the width of the heatmap
// Create an empty 2D array of size height & width
const initialZValues: number[][] = zeroArray2D([height, width]);
// Create a Heatmap Data-series. Pass the heatValues as a number[][] to the UniformHeatmapDataSeries
const heatmapDataSeries = new UniformHeatmapDataSeries({
xStart: 0,
xStep: 1,
yStart: 0,
yStep: 1,
zValues: initialZValues
});
// ...
// Later, update the data
initialZValues[5][6] = 123.4;
heatmapDataSeries.notifyDataChanged() // Notify SciChart that the data has changed
// You can also load an entirely new array with the function UniformHeatmapDataSeries.setZValues
const newZValues; // type number[][]
heatmapDataSeries.setZValues(newZValues);
For more details, including a live example of how to update 2D array data for heatmaps and contours, see the Uniform Heatmap documentation - Updating Heatmaps documentation page. The mechanism for contour plots is the same.
Contours at arbitrary levels
Using UniformContoursRenderableSeries.zLevels📘 we can specify arbitrary levels where to draw contour lines.
Use dataLabels📘 constructor option to tune the way contour labels look.
- TS
const { sciChartSurface, wasmContext } = await SciChartSurface.create(divElementId);
sciChartSurface.xAxes.add(new NumericAxis(wasmContext, { visibleRange: new NumberRange(-100, 100) }));
sciChartSurface.yAxes.add(new NumericAxis(wasmContext, { visibleRange: new NumberRange(-100, 100) }));
const heatmapWidth = 200;
const heatmapHeight = 200;
const colorPaletteMin = 0; // sets Z value for 0 gradient stop
const colorPaletteMax = 10000; // sets Z value for 1 gradient stop
// Create a Heatmap Data-series. Pass heatValues as a number[][] to the UniformHeatmapDataSeries
const initialZValues: number[][] = createSeries(heatmapWidth, heatmapHeight, colorPaletteMin, colorPaletteMax);
const heatmapDataSeries = new UniformHeatmapDataSeries(wasmContext, {
xStart: 0,
xStep: 1,
yStart: 0,
yStep: 1,
zValues: initialZValues
});
// Create a Contours RenderableSeries with the same data
const contourSeries = new UniformContoursRenderableSeries(wasmContext, {
dataSeries: heatmapDataSeries,
zLevels: [1000, 4000],
strokeThickness: 2,
stroke: "white",
dataLabels: {
style: {
fontSize: 20
},
color: "red",
labelRowCount: 1,
precision: 1,
numericFormat: ENumericFormat.Decimal
}
});
// Add the contours to the chart
sciChartSurface.renderableSeries.add(contourSeries);
const gradientStops = [
{ offset: 0, color: "#00008B" },
{ offset: 0.2, color: "#6495ED" },
{ offset: 0.4, color: "#006400" },
{ offset: 0.6, color: "#7FFF00" },
{ offset: 0.8, color: "#FFFF00" },
{ offset: 1.0, color: "#FF0000" }
];
// Create a background heatmap series with the same data and add to the chart
const heatmapSeries = new UniformHeatmapRenderableSeries(wasmContext, {
dataSeries: heatmapDataSeries,
//useLinearTextureFiltering: true,
opacity: 0.8,
colorMap: new HeatmapColorMap({
minimum: colorPaletteMin,
maximum: colorPaletteMax,
gradientStops
})
});
// Add heatmap to the chart
sciChartSurface.renderableSeries.add(heatmapSeries);
Individual colouring for contour lines
By default a contours series draws every line in one flat colour, taken from stroke (or from
majorLineStyle📘 /
minorLineStyle📘 when those are set).
That makes it hard to tell one level from another without reading the labels.
UniformContoursRenderableSeries.colorMapMode📘 colours each contour line individually, by its own z-value, using the series colorMap📘 — so the lines read as a legend on their own, with no background heatmap needed.
The mode is an EContourColorMapMode📘:
EContourColorMapMode | Effect |
|---|---|
SingleColor📘 | The default. Every line takes the flat colour from majorLineStyle / minorLineStyle. colorMap is used only to normalise the data, not to colour lines |
GradientColors📘 | Each line's colour is interpolated from the gradient at that line's z-value, over the colorMap minimum..maximum range — so the colours follow the data, exactly as a heatmap's do |
AlternateColors📘 | Each line takes the next gradientStops colour in turn, ignoring the offsets. With stops red, green, blue the lines cycle red, green, blue, red, ... Useful for telling adjacent levels apart when the data range is narrow |
To colour each line by its z-value, set colorMapMode together with a colorMap:
- TS
// Demonstrates how to colour each contour line by its own z-value
const {
SciChartSurface,
NumericAxis,
HeatmapColorMap,
UniformHeatmapDataSeries,
UniformContoursRenderableSeries,
EContourColorMapMode,
SciChartJsNavyTheme
} = SciChart;
// or, for npm, import { SciChartSurface, ... } from "scichart"
const { wasmContext, sciChartSurface } = await SciChartSurface.create(divElementId, {
theme: new SciChartJsNavyTheme()
});
sciChartSurface.xAxes.add(new NumericAxis(wasmContext));
sciChartSurface.yAxes.add(new NumericAxis(wasmContext));
// Create a Heatmap Data-series. zValues are heatValues as a 2D array (number[][])
const heatmapDataSeries = new UniformHeatmapDataSeries(wasmContext, {
zValues: generateExampleData(300, 200),
xStart: 0,
xStep: 1,
yStart: 0,
yStep: 1
});
const contourSeries = new UniformContoursRenderableSeries(wasmContext, {
dataSeries: heatmapDataSeries,
// Draw a contour line every 20 z-values, from 20 up to the data maximum
zMin: 20,
zStep: 20,
strokeThickness: 3,
// New in v6: colour every contour line individually, by its own z-value ...
colorMapMode: EContourColorMapMode.GradientColors,
// ... reading the colour out of this colorMap, exactly as a heatmap would
colorMap: new HeatmapColorMap({
minimum: 0,
maximum: 180,
gradientStops: [
{ offset: 0, color: "#264B93" },
{ offset: 0.35, color: "#50C7E0" },
{ offset: 0.6, color: "#67BDAF" },
{ offset: 0.8, color: "#F48420" },
{ offset: 1, color: "#EC0F6C" }
]
}),
// Label each line with its z-value, so you can read which value each colour stands for
dataLabels: {
style: { fontSize: 12 },
color: "#FFFFFF",
labelRowCount: 2,
precision: 0
}
});
sciChartSurface.renderableSeries.add(contourSeries);
In the code above:
zMinandzStepplace a contour line every20z-values, so there is one ring per level.colorMapMode: EContourColorMapMode.GradientColorsswitches on per-line colouring.- The colorMap📘 supplies both the gradient and the range it is mapped over. A line at
z = 90sits halfway throughminimum: 0..maximum: 180, so it takes the colour at gradient offset0.5. strokeThicknessstill sets the line width —colorMapModeonly overrides the colour. SetmajorLineStyleandminorLineStyle(with minorsPerMajor📘) to give majors and minors different thicknesses; both still take their colour from thecolorMap.dataLabelsputs the z-value on each line, so you can read which value a colour stands for. See Contours at arbitrary levels above.
Per-line colouring needs a colorMap with gradientStops. Set colorMapMode without one and the series silently
falls back to the flat single-colour path.
It also changes how the lines are drawn: SingleColor emits one draw call for all majors and one for all minors, while
the per-line modes enumerate the levels and emit one draw call per level. On a chart with very many levels, prefer
SingleColor. colorMapMode works with zLevels too.
Laying labels along the contour lines
The built-in ContoursDataLabelProvider📘 places labels by scanning labelRowCount📘 evenly spaced horizontal rows across the chart and dropping an upright label wherever a row crosses a contour line. That works well when the lines run roughly horizontally and are well separated. It works less well when they are steep or tightly packed: the same line picks up one label per row, labels from neighbouring levels end up side by side, and none of them tell you which line they belong to.
Tools like matplotlib's clabel and Plotly instead put each label on its line, rotated to follow it. There is no
built-in provider for that, but dataLabelProvider is an ordinary property, so you can subclass the built-in one and
replace generateDataLabels with your own layout. The chart on the right below does exactly that:
- Chart setup
- Custom label provider
- Contour tracer
const Z_LEVELS = [-60, -50, -40, -30, -20, -10, 0, 10, 20, 30];
const createChart = async (divElementId: string, dataLabelProvider: ContoursDataLabelProvider) => {
const { wasmContext, sciChartSurface } = await SciChartSurface.create(divElementId, {
theme: new SciChartJsNavyTheme()
});
sciChartSurface.xAxes.add(new NumericAxis(wasmContext));
sciChartSurface.yAxes.add(new NumericAxis(wasmContext));
const SIZE = 300;
const heatmapDataSeries = new UniformHeatmapDataSeries(wasmContext, {
zValues: generateExampleData(SIZE, SIZE),
xStart: -4,
xStep: 8 / SIZE,
yStart: -4,
yStep: 8 / SIZE
});
const contourSeries = new UniformContoursRenderableSeries(wasmContext, {
dataSeries: heatmapDataSeries,
zLevels: Z_LEVELS,
strokeThickness: 2,
stroke: "#50C7E0",
colorMap: new HeatmapColorMap({ minimum: -70, maximum: 45, gradientStops: [] })
});
// dataLabelProvider is a plain property, so a custom provider drops straight in
contourSeries.dataLabelProvider = dataLabelProvider;
sciChartSurface.renderableSeries.add(contourSeries);
sciChartSurface.chartModifiers.add(new ZoomPanModifier(), new ZoomExtentsModifier(), new MouseWheelZoomModifier());
return sciChartSurface;
};
const drawExample = async () => {
// Left: the built-in provider. Labels are upright, and land wherever its fixed rows cross a line
await createChart(
"scichart-root",
new ContoursDataLabelProvider({
style: { fontSize: 12 },
color: "#FFFFFF",
labelRowCount: 3,
precision: 0
})
);
// Right: the custom provider above. Labels follow the lines and space themselves along them
await createChart(
"scichart-root-2",
new AlongLineContoursDataLabelProvider({
style: { fontSize: 12 },
color: "#FFFFFF",
labelSpacing: 130,
rotateToLine: true,
precision: 0
})
);
};
export interface IAlongLineLabelOptions extends IContoursDataLabelProviderOptions {
/** Smallest gap, in pixels, allowed between two labels on the same contour line. Default 140 */
labelSpacing?: number;
/** Rotate each label to follow the line it sits on. Default true */
rotateToLine?: boolean;
}
/**
* Lays contour labels *along* their lines, rotated to follow them, in the manner of matplotlib
* clabel. Subclasses the built-in provider and replaces generateDataLabels wholesale: the built-in
* one scans a number of fixed horizontal rows, which puts an upright label wherever a row happens
* to cross a line. Everything else - style, color, numericFormat, precision, isEnabled - is
* inherited unchanged.
*/
export class AlongLineContoursDataLabelProvider extends ContoursDataLabelProvider {
public labelSpacing: number;
public rotateToLine: boolean;
public constructor(options?: IAlongLineLabelOptions) {
super(options);
this.labelSpacing = options?.labelSpacing ?? 140;
this.rotateToLine = options?.rotateToLine ?? true;
}
/** Rotated text needs the alternative, transformable font instance */
protected get useRotation(): boolean {
return this.rotateToLine;
}
public generateDataLabels(renderContext: WebGlRenderContext2D, renderPassData: RenderPassData): void {
this.dataLabels = [];
if (!this.isEnabled || !this.style?.fontFamily || !this.style?.fontSize) return;
const series = this.parentSeries as UniformContoursRenderableSeries;
const dataSeries = series.dataSeries as UniformHeatmapDataSeries;
if (!dataSeries) return;
// The levels the contour shader is drawing: an explicit list, or one every minor step
const params = series.getContourDrawingParams();
const levels: number[] = params.zLevels?.length
? params.zLevels.slice().sort((left, right) => left - right)
: [];
if (levels.length === 0 && params.minorStepZ > 0) {
for (let z = params.zMin + params.zOffset; z <= params.zMax; z += params.minorStepZ) {
levels.push(z);
}
}
if (levels.length === 0) return;
const segments = traceContourSegments(dataSeries.getZValues(), levels);
if (segments.length === 0) return;
// The contour surface is a texture stretched over [xMin, xMax] x [yMin, yMax], with the data
// samples at cell centres. Mapping grid indices to pixels the same way puts the labels exactly
// on the drawn lines, whatever the axis type.
const { xCoordinateCalculator: xCalc, yCoordinateCalculator: yCalc } = renderPassData;
const xStartPx = xCalc.getCoordinate(params.xMin);
const yStartPx = yCalc.getCoordinate(params.yMin);
const perColumn = (xCalc.getCoordinate(params.xMax) - xStartPx) / dataSeries.arrayWidth;
const perRow = (yCalc.getCoordinate(params.yMax) - yStartPx) / dataSeries.arrayHeight;
const font = renderContext.getFont(DpiHelper.adjustTextStyle(this.style), this.useRotation);
const lineSpacing = this.getLineSpacing();
const bounds = this.textBounds;
const color = parseColorToUIntArgb(this.color);
const viewRect = series.parentSurface.seriesViewRect;
const minGap = Math.max(1, this.labelSpacing) * DpiHelper.PIXEL_RATIO;
const placed: { level: number; x: number; y: number; rect: Rect }[] = [];
for (const segment of segments) {
const ax = xStartPx + (segment.i0 + 0.5) * perColumn;
const ay = yStartPx + (segment.j0 + 0.5) * perRow;
const bx = xStartPx + (segment.i1 + 0.5) * perColumn;
const by = yStartPx + (segment.j1 + 0.5) * perRow;
const centreX = (ax + bx) / 2;
const centreY = (ay + by) / 2;
const dx = bx - ax;
const dy = by - ay;
if (dx === 0 && dy === 0) continue;
// Thin the labels out before doing any text measurement: one per labelSpacing along a line
const tooClose = placed.some(
other =>
other.level === segment.level &&
(other.x - centreX) ** 2 + (other.y - centreY) ** 2 < minGap * minGap
);
if (tooClose) continue;
// Screen y grows downwards and the text is drawn counter-clockwise, hence the minus.
// Fold the angle into a quarter turn either way so the text is never upside down.
let rotationAngle = this.rotateToLine ? -Math.atan2(dy, dx) : 0;
if (rotationAngle > Math.PI / 2) rotationAngle -= Math.PI;
else if (rotationAngle < -Math.PI / 2) rotationAngle += Math.PI;
const text = formatNumber(segment.level, this.numericFormat, this.precision);
font.CalculateStringBounds(text, bounds, lineSpacing);
const textWidth = bounds.m_fWidth;
const textHeight = bounds.m_fHeight;
// position.y is the alphabetic baseline of the first line, not the top of the text
const lineBounds = bounds.GetLineBounds(0);
const firstLineHeight = lineBounds.m_fHeight;
lineBounds.delete();
// Axis-aligned box of the rotated text, used for the clipping and overlap tests
const sin = Math.abs(Math.sin(rotationAngle));
const cos = Math.abs(Math.cos(rotationAngle));
const boxWidth = textWidth * cos + textHeight * sin;
const boxHeight = textWidth * sin + textHeight * cos;
const rect = new Rect(centreX - boxWidth / 2, centreY - boxHeight / 2, boxWidth, boxHeight);
// Drop anything hanging outside the chart, or landing on a label already placed
if (rect.x < 0 || rect.y < 0 || rect.right > viewRect.width || rect.bottom > viewRect.height) continue;
if (placed.some(other => rectsOverlap(other.rect, rect))) continue;
placed.push({ level: segment.level, x: centreX, y: centreY, rect });
this.dataLabels.push({
text,
position: new Point(centreX - textWidth / 2, centreY - textHeight / 2 + firstLineHeight),
rect,
color,
dataX: xCalc.getDataValue(centreX),
dataY: yCalc.getDataValue(centreY),
rotationAngle,
rotationCenter: new Point(centreX, centreY)
});
}
}
}
const rectsOverlap = (first: Rect, second: Rect): boolean =>
first.left < second.right && second.left < first.right && first.top < second.bottom && second.top < first.bottom;
/** One straight piece of a contour line, in fractional grid-index space (`i` = column, `j` = row) */
type TContourSegment = { level: number; i0: number; j0: number; i1: number; j1: number };
/** Where between two samples the contour crosses */
const crossing = (from: number, to: number, level: number) => (level - from) / (to - from);
/**
* Traces the contour lines of a 2D array with marching squares, in one pass over the cells for all
* levels. Returns loose segments rather than stitched polylines - enough to place a label on a line
* and know which way the line runs there, which is all the label provider below needs.
*/
const traceContourSegments = (zValues: NumberArray[], levels: number[]): TContourSegment[] => {
const segments: TContourSegment[] = [];
const height = zValues.length;
const width = zValues[0].length;
for (let j = 0; j < height - 1; j++) {
for (let i = 0; i < width - 1; i++) {
// The four corners of this cell, counter-clockwise from bottom-left
const v00 = zValues[j][i];
const v10 = zValues[j][i + 1];
const v11 = zValues[j + 1][i + 1];
const v01 = zValues[j + 1][i];
const min = Math.min(v00, v10, v11, v01);
const max = Math.max(v00, v10, v11, v01);
for (const level of levels) {
// With "corner is above" meaning value > level, a cell is crossed exactly when this holds
if (level < min || level >= max) continue;
const bottom = () => ({ i: i + crossing(v00, v10, level), j });
const top = () => ({ i: i + crossing(v01, v11, level), j: j + 1 });
const left = () => ({ i, j: j + crossing(v00, v01, level) });
const right = () => ({ i: i + 1, j: j + crossing(v10, v11, level) });
const add = (from: { i: number; j: number }, to: { i: number; j: number }) =>
segments.push({ level, i0: from.i, j0: from.j, i1: to.i, j1: to.j });
// Which corners are above the level decides which edges the line enters and leaves by
const bits =
(v00 > level ? 1 : 0) | (v10 > level ? 2 : 0) | (v11 > level ? 4 : 0) | (v01 > level ? 8 : 0);
switch (bits) {
case 1:
case 14:
add(left(), bottom());
break;
case 2:
case 13:
add(bottom(), right());
break;
case 3:
case 12:
add(left(), right());
break;
case 4:
case 11:
add(right(), top());
break;
case 6:
case 9:
add(bottom(), top());
break;
case 7:
case 8:
add(top(), left());
break;
// Saddle: the cell centre decides which pair of corners is joined
case 5:
case 10: {
const centreAbove = (v00 + v10 + v11 + v01) / 4 > level;
if (bits === 5 ? !centreAbove : centreAbove) {
add(left(), bottom());
add(right(), top());
} else {
add(bottom(), right());
add(top(), left());
}
break;
}
}
}
}
}
return segments;
};
How it works:
- Find the lines. The contour geometry lives in a shader, so it is not readable from JavaScript. The Contour tracer tab re-derives it with marching squares: for every cell of the
zValuesgrid, the four corners' positions relative to a level decide which cell edges the line enters and leaves by, and the crossing point on each edge is linearly interpolated. It returns loose segments rather than stitched polylines — enough to know where a line is and which way it runs, which is all a label needs. - Ask the series which levels it drew. getContourDrawingParams()📘 resolves
zLevels, orzMin/zMax/zStep/zOffset, into the levels actually on screen, so the labels can never disagree with the lines. - Map grid indices to pixels. The contour surface is a texture stretched over
[xMin, xMax] x [yMin, yMax]with the data samples at cell centres, hence the+ 0.5. Going through the render pass's coordinate calculators keeps the labels on the lines under any zoom, pan or axis type. - Thin, then place. A segment is skipped if a label for the same level already sits within
labelSpacingpixels — a cheap distance test done before any text measurement. What survives gets arotationAnglefrom the segment's own direction, folded into a quarter turn either way so text is never upside down, and is dropped if its box leaves the chart or lands on a label already placed. - Override
useRotation. Returningtruemakes the base class request the transformable font instance; without itrotationAngleis ignored.
This is a deliberately small recipe, not a drop-in replacement for the built-in provider. It traces the whole grid every render pass rather than caching, spaces labels by straight-line distance rather than arc length, treats major and minor lines alike, and does not handle vertical (rotated) charts. Its label positions are also not stable under zoom — because the spacing test walks segments in grid order, labels can shift or swap as the chart is zoomed. Pinning them takes anchoring each label to a position measured in zoom-invariant grid-index arc length, which is what makes a production implementation of this considerably longer.