diff --git a/capabilities.json b/capabilities.json index 8b07b4c..2a3f843 100644 --- a/capabilities.json +++ b/capabilities.json @@ -1328,7 +1328,7 @@ } } }, - "label_options" : { + "labels" : { "displayName" : "Value Label Options", "properties" : { "show_labels": { @@ -1389,6 +1389,26 @@ "label_line_max_length": { "displayName": "Max Connecting Line Length (px)", "type": { "numeric": true } + }, + "label_marker_show": { + "displayName": "Show Line Markers", + "type" : { "bool" : true } + }, + "label_marker_offset": { + "displayName": "Marker Offset from Value (px)", + "type": { "numeric": true } + }, + "label_marker_size": { + "displayName": "Marker Size", + "type": { "numeric": true } + }, + "label_marker_colour":{ + "displayName": "Marker Fill Colour", + "type": { "fill": { "solid": { "color": true } } } + }, + "label_marker_outline_colour":{ + "displayName": "Marker Outline Colour", + "type": { "fill": { "solid": { "color": true } } } } } } diff --git a/pbiviz.json b/pbiviz.json index 9288a58..1741a2a 100644 --- a/pbiviz.json +++ b/pbiviz.json @@ -4,7 +4,7 @@ "displayName":"SPC Charts", "guid":"PBISPC", "visualClassName":"Visual", - "version":"1.4.4.19", + "version":"1.4.4.20", "description":"A PowerBI custom visual for SPC charts", "supportUrl":"https://github.com/AUS-DOH-Safety-and-Quality/PowerBI-SPC", "gitHubUrl":"https://github.com/AUS-DOH-Safety-and-Quality/PowerBI-SPC" diff --git a/src/Classes/viewModelClass.ts b/src/Classes/viewModelClass.ts index dc4bae5..d61c0e9 100644 --- a/src/Classes/viewModelClass.ts +++ b/src/Classes/viewModelClass.ts @@ -73,6 +73,7 @@ export type plotData = { tooltip: VisualTooltipDataItem[]; label: { text_value: string, + aesthetics: defaultSettingsType["labels"], x: number, y: number }; @@ -599,6 +600,7 @@ export default class viewModelClass { this.inputSettings.settings, this.inputSettings.derivedSettings), label: { text_value: this.inputData.labels?.[index], + aesthetics: this.inputData.label_formatting[index], x: null, y: null } diff --git a/src/D3 Plotting Functions/D3 Modules/index.ts b/src/D3 Plotting Functions/D3 Modules/index.ts index fa0eb92..2b23260 100644 --- a/src/D3 Plotting Functions/D3 Modules/index.ts +++ b/src/D3 Plotting Functions/D3 Modules/index.ts @@ -1,6 +1,6 @@ export { select, selectAll, type Selection, type BaseType } from "d3-selection"; export { groups, leastIndex } from "d3-array"; -export { line } from "d3-shape" +export { line, symbol, symbolTriangle } from "d3-shape" export { type Axis, axisBottom, axisLeft } from "d3-axis" export { type ScaleLinear, scaleLinear, type NumberValue } from "d3-scale" export { drag } from "d3-drag" diff --git a/src/D3 Plotting Functions/drawValueLabels.ts b/src/D3 Plotting Functions/drawValueLabels.ts index 14821e0..aa7de33 100644 --- a/src/D3 Plotting Functions/drawValueLabels.ts +++ b/src/D3 Plotting Functions/drawValueLabels.ts @@ -1,68 +1,82 @@ import * as d3 from "./D3 Modules"; import type { svgBaseType, Visual } from "../visual"; -import { plotData } from "../Classes"; +import type { plotData } from "../Classes"; const degToRad: number = Math.PI / 180; -const labelFormatting = function(selection: d3.Selection, visualObj: Visual) { - let label_options = visualObj.viewModel.inputSettings.settings.label_options; +const calcCoord = function(axis: "x" | "y", d: plotData, visualObj: Visual) { const plotHeight: number = visualObj.viewModel.svgHeight; const xAxisHeight: number = plotHeight - visualObj.viewModel.plotProperties.yAxis.start_padding; - const label_position: string = visualObj.viewModel.inputSettings.settings.label_options.label_position; - let y_offset: number = visualObj.viewModel.inputSettings.settings.label_options.label_y_offset; - let line_offset: number = visualObj.viewModel.inputSettings.settings.label_options.label_line_offset; - line_offset = label_position === "top" ? line_offset : -(line_offset + visualObj.viewModel.inputSettings.settings.label_options.label_size / 2); + const label_position: string = d.label.aesthetics.label_position; + let y_offset: number = d.label.aesthetics.label_y_offset; const label_height: number = label_position === "top" ? (0 + y_offset) : (xAxisHeight - y_offset); - const theta: number = visualObj.viewModel.inputSettings.settings.label_options.label_angle_offset; - const max_line_length: number = visualObj.viewModel.inputSettings.settings.label_options.label_line_max_length; + const y: number = visualObj.viewModel.plotProperties.yScale(d.value); + + let side_length: number = label_position === "top" ? (y - label_height) : (label_height - y); + side_length = Math.min(side_length, d.label.aesthetics.label_line_max_length); + if (axis === "x") { + const theta: number = d.label.aesthetics.label_angle_offset; + const x: number = visualObj.viewModel.plotProperties.xScale(d.x); + // When angle offset provided, calculate adjusted x coordinate using law of sines + const theta_2: number = 180 - (90 + theta); + + return d.label.x ?? Math.sin(theta * degToRad) * side_length / Math.sin(theta_2 * degToRad) + x; + } else { + return d.label.y ?? y + (label_position === "top" ? -side_length : side_length); + } + +} + +const labelFormatting = function(selection: d3.Selection, visualObj: Visual) { selection.select("text") .text(d => d.label.text_value) - .attr("x", d => { - const y: number = visualObj.viewModel.plotProperties.yScale(d.value); - let side_length: number = label_position === "top" ? (y - label_height) : (label_height - y); - side_length = Math.min(side_length, max_line_length); - const x: number = visualObj.viewModel.plotProperties.xScale(d.x); - // When angle offset provided, calculate adjusted x coordinate using law of sines - const theta_2: number = 180 - (90 + theta); - return d.label.x ?? Math.sin(theta * degToRad) * side_length / Math.sin(theta_2 * degToRad) + x; - }) - .attr("y", d => { - const y: number = visualObj.viewModel.plotProperties.yScale(d.value); - let side_length: number = label_position === "top" ? (y - label_height) : (label_height - y); - side_length = Math.min(side_length, max_line_length); - return d.label.y ?? y + (label_position === "top" ? -side_length : side_length); - }) + .attr("x", d => calcCoord("x", d, visualObj)) + .attr("y", d => calcCoord("y", d, visualObj)) .style("text-anchor", "middle") - .style("font-size", `${label_options.label_size}px`) - .style("font-family", label_options.label_font) - .style("fill", label_options.label_colour); + .style("font-size", d => `${d.label.aesthetics.label_size}px`) + .style("font-family", d => d.label.aesthetics.label_font) + .style("fill", d => d.label.aesthetics.label_colour); selection.select("line") - .attr("x1", d => { - const y: number = visualObj.viewModel.plotProperties.yScale(d.value); - let side_length: number = label_position === "top" ? (y - label_height) : (label_height - y); - side_length = Math.min(side_length, max_line_length); - const x: number = visualObj.viewModel.plotProperties.xScale(d.x); - // When angle offset provided, calculate adjusted x coordinate using law of sines - const theta_2: number = 180 - (90 + theta); - return d.label.x ?? Math.sin(theta * degToRad) * side_length / Math.sin(theta_2 * degToRad) + x; - }) + .attr("x1", d => calcCoord("x", d, visualObj)) .attr("y1", d => { - const y: number = visualObj.viewModel.plotProperties.yScale(d.value); - let side_length: number = label_position === "top" ? (y - label_height) : (label_height - y); - side_length = Math.min(side_length, max_line_length); - return (d.label.y ?? y + (label_position === "top" ? -side_length : side_length)) + line_offset; + const label_position: string = d.label.aesthetics.label_position; + let line_offset: number = d.label.aesthetics.label_line_offset; + line_offset = label_position === "top" ? line_offset : -(line_offset + d.label.aesthetics.label_size / 2); + return calcCoord("y", d, visualObj) + line_offset }) .attr("x2", d => visualObj.viewModel.plotProperties.xScale(d.x)) - .attr("y2", d => visualObj.viewModel.plotProperties.yScale(d.value)) - .style("stroke", visualObj.viewModel.inputSettings.settings.label_options.label_line_colour) - .style("stroke-width", d => (d.label.text_value ?? "") === "" ? 0 : visualObj.viewModel.inputSettings.settings.label_options.label_line_width) - .style("stroke-dasharray", visualObj.viewModel.inputSettings.settings.label_options.label_line_type); + .attr("y2", d => { + const label_position: string = d.label.aesthetics.label_position; + let marker_offset: number = d.label.aesthetics.label_marker_offset + d.label.aesthetics.label_size / 2; + marker_offset = label_position === "top" ? -marker_offset : marker_offset; + return visualObj.viewModel.plotProperties.yScale(d.value) + marker_offset + }) + .style("stroke", visualObj.viewModel.inputSettings.settings.labels.label_line_colour) + .style("stroke-width", d => (d.label.text_value ?? "") === "" ? 0 : visualObj.viewModel.inputSettings.settings.labels.label_line_width) + .style("stroke-dasharray", visualObj.viewModel.inputSettings.settings.labels.label_line_type); + + selection.select("path") + .attr("d", d => { + const show_marker: boolean = d.label.aesthetics.label_marker_show && (d.label.text_value ?? "") !== ""; + const marker_size: number = show_marker ? Math.pow(d.label.aesthetics.label_marker_size, 2) : 0; + return d3.symbol().type(d3.symbolTriangle).size(marker_size)() + }) + .attr("transform", d => { + const label_position: string = d.label.aesthetics.label_position; + const x: number = visualObj.viewModel.plotProperties.xScale(d.x); + const y: number = visualObj.viewModel.plotProperties.yScale(d.value); + let marker_offset: number = d.label.aesthetics.label_marker_offset + d.label.aesthetics.label_size / 2; + marker_offset = label_position === "top" ? -marker_offset : marker_offset; + return `translate(${x},${y + marker_offset}) rotate(${label_position === "top" ? 180 : 0})`; + }) + .style("fill", d => d.label.aesthetics.label_marker_colour) + .style("stroke", d => d.label.aesthetics.label_marker_outline_colour) } export default function drawLabels(selection: svgBaseType, visualObj: Visual) { - if (!visualObj.viewModel.inputSettings.settings.label_options.show_labels) { + if (!visualObj.viewModel.inputSettings.settings.labels.show_labels) { selection.select(".text-labels").remove(); return; } @@ -71,9 +85,9 @@ export default function drawLabels(selection: svgBaseType, visualObj: Visual) { selection.append("g").classed("text-labels", true); } - const label_position: string = visualObj.viewModel.inputSettings.settings.label_options.label_position; - let line_offset: number = visualObj.viewModel.inputSettings.settings.label_options.label_line_offset; - line_offset = label_position === "top" ? line_offset : -(line_offset + visualObj.viewModel.inputSettings.settings.label_options.label_size / 2); + const label_position: string = visualObj.viewModel.inputSettings.settings.labels.label_position; + let line_offset: number = visualObj.viewModel.inputSettings.settings.labels.label_line_offset; + line_offset = label_position === "top" ? line_offset : -(line_offset + visualObj.viewModel.inputSettings.settings.labels.label_size / 2); const dragFun = d3.drag().on("drag", function(e) { e.subject.label.x = e.sourceEvent.x; @@ -97,6 +111,7 @@ export default function drawLabels(selection: svgBaseType, visualObj: Visual) { let grp = enter.append("g").classed("text-group-inner", true) grp.append("text"); grp.append("line"); + grp.append("path") grp.call(labelFormatting, visualObj) .call(dragFun); diff --git a/src/Functions/extractInputData.ts b/src/Functions/extractInputData.ts index 6c9a8ee..9d89d4b 100644 --- a/src/Functions/extractInputData.ts +++ b/src/Functions/extractInputData.ts @@ -16,6 +16,7 @@ export type dataObject = { groupings: string[]; groupingIndexes: number[]; scatter_formatting: defaultSettingsType["scatter"][]; + label_formatting: defaultSettingsType["labels"][]; tooltips: VisualTooltipDataItem[][]; labels: string[]; warningMessage: string; @@ -35,6 +36,7 @@ function invalidInputData(inputValidStatus: ValidationT): dataObject { groupings: null, groupingIndexes: null, scatter_formatting: null, + label_formatting: null, tooltips: null, labels: null, warningMessage: inputValidStatus.error, @@ -59,6 +61,7 @@ export default function extractInputData(inputView: DataViewCategorical, const labels: string[] = extractDataColumn(inputView, "labels", inputSettings, idxs); const highlights: powerbi.PrimitiveValue[] = idxs.map(d => inputView?.values?.[0]?.highlights?.[d]); let scatter_cond = extractConditionalFormatting(inputView, "scatter", inputSettings, idxs)?.values; + let labels_cond = extractConditionalFormatting(inputView, "labels", inputSettings, idxs)?.values; let alt_targets: number[] = extractConditionalFormatting(inputView, "lines", inputSettings, idxs) ?.values .map(d => inputSettings.lines.show_alt_target ? d.alt_target : null); @@ -153,6 +156,7 @@ export default function extractInputData(inputView: DataViewCategorical, groupings: valid_groupings, groupingIndexes: groupingIndexes, scatter_formatting: extractValues(scatter_cond, valid_ids), + label_formatting: extractValues(labels_cond, valid_ids), warningMessage: removalMessages.length > 0 ? removalMessages.join("\n") : "", alt_targets: valid_alt_targets, speclimits_lower: extractValues(speclimits_lower, valid_ids), diff --git a/src/defaultSettings.ts b/src/defaultSettings.ts index 5f936c9..a5e1a6f 100644 --- a/src/defaultSettings.ts +++ b/src/defaultSettings.ts @@ -264,7 +264,7 @@ const defaultSettings = { download_options: { show_button: { default: false } }, - label_options: { + labels: { show_labels: { default: true }, label_position: { default: "top", valid: ["top", "bottom"] }, label_y_offset: { default: 20 }, @@ -276,7 +276,12 @@ const defaultSettings = { label_line_colour: colourOptions.standard, label_line_width: { default: 1, valid: lineOptions.width.valid }, label_line_type: { default: "10 0", valid: lineOptions.type.valid }, - label_line_max_length: { default: 1000, valid: { numberRange: { min: 0, max: 10000 }}} + label_line_max_length: { default: 1000, valid: { numberRange: { min: 0, max: 10000 }}}, + label_marker_show: { default: true }, + label_marker_offset: { default: 5 }, + label_marker_size: { default: 3, valid: { numberRange: { min: 0, max: 100 }}}, + label_marker_colour: colourOptions.standard, + label_marker_outline_colour: colourOptions.standard } };