From 585aa36f64f468984ed5a493146a43893b463313 Mon Sep 17 00:00:00 2001 From: Bill Ticehurst Date: Mon, 29 Jan 2024 16:11:37 -0800 Subject: [PATCH] RE and widget updates (#1080) In trying to add some desired features and improvements, I hit a few existing bugs and glitches. In the process of trying to address those I ended up doing quite a bit of refactoring. (This PR actually removes more code than it adds, despite adding several new features). - Fixed issue where result name was blank if only one row was present (i.e. the run selected just one qubit type) - Note: There is still a bug with sometimes losing the current selection when new results/rows are added, which can cause the row 'name' to change due to the recent change to dynamically calculate the series/row name. This is because the name is used as the unique key for some saved state. I look more into this later. - Fixed issue where multiple selections could occur on the scatter chart. - Fixed issue where an error was reporting in the editor for UX components importing from the ticks module. - Fixed some color rendering issues with JupyterLab in the browser in dark theme. - Made some of the DOM manipulation code more declarative and canonical, reducing code size and complexity. - Updated hover, selection, and tooltip display behavior to be more consistent. - Added keyboard up/down handler to change the selected row when the results table is focused. - The column selection menu now closes when you click outside of it. - Minor adjustments to chart layout (e.g. reducing the left padding) I think we want much of this for 1.1, so hopefully can get it in tomorrow (Monday). I've tested in VS Code and Jupyter Notebooks. CC @ivanbasov --- npm/src/browser.ts | 2 + npm/src/main.ts | 2 + npm/src/{ux/ticks.ts => utils.ts} | 37 +++ npm/test/basics.js | 6 +- npm/ux/data.ts | 3 - npm/ux/estimatesOverview.tsx | 148 ++++----- npm/ux/qsharp-ux.css | 62 ++-- npm/ux/resultsTable.tsx | 87 +++-- npm/ux/scatterChart.tsx | 519 ++++++++++++------------------ npm/ux/tsconfig.json | 1 - vscode/src/webview/webview.tsx | 7 - 11 files changed, 407 insertions(+), 467 deletions(-) rename npm/src/{ux/ticks.ts => utils.ts} (78%) diff --git a/npm/src/browser.ts b/npm/src/browser.ts index 57c5bb82aa..2853f4ece7 100644 --- a/npm/src/browser.ts +++ b/npm/src/browser.ts @@ -249,3 +249,5 @@ export type { } from "../lib/web/qsc_wasm.js"; export { type IStructStepResult, StepResultId } from "../lib/web/qsc_wasm.js"; export { type LanguageServiceEvent } from "./language-service/language-service.js"; + +export * as utils from "./utils.js"; diff --git a/npm/src/main.ts b/npm/src/main.ts index 38736e480e..c134f5beaf 100644 --- a/npm/src/main.ts +++ b/npm/src/main.ts @@ -145,3 +145,5 @@ export function getLanguageServiceWorker(): ILanguageServiceWorker { return proxy; } + +export * as utils from "./utils.js"; diff --git a/npm/src/ux/ticks.ts b/npm/src/utils.ts similarity index 78% rename from npm/src/ux/ticks.ts rename to npm/src/utils.ts index 495b832a71..05b230fbcc 100644 --- a/npm/src/ux/ticks.ts +++ b/npm/src/utils.ts @@ -162,3 +162,40 @@ export function CreateTimeTicks(min: number, max: number): Tick[] { return result; } + +type SeriesOfPoints = Array<{ items: Array<{ x: number; y: number }> }>; + +// Given an array of object, and each of those objects has an 'items' property +// that is another array of objects with number properties x & y, return a +// tuple of [minX, maxX, minY, maxY] covering all items given +export function getMinMaxXYForItems( + data: SeriesOfPoints, +): [number, number, number, number] { + return data.reduce( + (priorSeriesResult, curr) => + curr.items.reduce( + (priorItemResult, curr) => [ + Math.min(priorItemResult[0], curr.x), + Math.max(priorItemResult[1], curr.x), + Math.min(priorItemResult[2], curr.y), + Math.max(priorItemResult[3], curr.y), + ], + priorSeriesResult, + ), + [Number.MAX_VALUE, Number.MIN_VALUE, Number.MAX_VALUE, Number.MIN_VALUE], + ); +} + +export function getRanges(data: SeriesOfPoints, rangeCoefficient: number) { + const [minX, maxX, minY, maxY] = getMinMaxXYForItems(data); + + const rangeX = { + min: minX / rangeCoefficient, + max: maxX * rangeCoefficient, + }; + const rangeY = { + min: minY / rangeCoefficient, + max: maxY * rangeCoefficient, + }; + return { rangeX, rangeY }; +} diff --git a/npm/test/basics.js b/npm/test/basics.js index 049b9af50f..7fcc3ef5ae 100644 --- a/npm/test/basics.js +++ b/npm/test/basics.js @@ -12,11 +12,11 @@ import { getLanguageService, getLanguageServiceWorker, getDebugServiceWorker, + utils, } from "../dist/main.js"; import { QscEventTarget } from "../dist/compiler/events.js"; import { getAllKatas, getExerciseSources, getKata } from "../dist/katas.js"; import samples from "../dist/samples.generated.js"; -import { CreateIntegerTicks, CreateTimeTicks } from "../dist/ux/ticks.js"; /** @type {import("../dist/log.js").TelemetryEvent[]} */ const telemetryEvents = []; @@ -1146,7 +1146,7 @@ function getLabels(ticks) { function runAndAssertIntegerTicks(min, max, expected) { const message = `min: ${min}, max: ${max}`; assert.deepStrictEqual( - getValues(CreateIntegerTicks(min, max)), + getValues(utils.CreateIntegerTicks(min, max)), expected, message, ); @@ -1155,7 +1155,7 @@ function runAndAssertIntegerTicks(min, max, expected) { function runAndAssertTimeTicks(min, max, expected) { const message = `min: ${min}, max: ${max}`; assert.deepStrictEqual( - getLabels(CreateTimeTicks(min, max)), + getLabels(utils.CreateTimeTicks(min, max)), expected, message, ); diff --git a/npm/ux/data.ts b/npm/ux/data.ts index c6b9c9f67d..70131b1405 100644 --- a/npm/ux/data.ts +++ b/npm/ux/data.ts @@ -10,7 +10,6 @@ export type ReData = { tfactory: any; errorBudget: any; logicalCounts: any; - new: boolean; frontierEntries: FrontierEntry[]; }; @@ -23,7 +22,6 @@ export type SingleEstimateResult = { tfactory: any; errorBudget: any; logicalCounts: any; - new: boolean; }; export type FrontierEntry = { @@ -62,7 +60,6 @@ export function CreateSingleEstimateResult( tfactory: entry.tfactory, errorBudget: entry.errorBudget, logicalCounts: input.logicalCounts, - new: input.new, }; } } diff --git a/npm/ux/estimatesOverview.tsx b/npm/ux/estimatesOverview.tsx index 5ef066bd33..dcf4820b22 100644 --- a/npm/ux/estimatesOverview.tsx +++ b/npm/ux/estimatesOverview.tsx @@ -13,14 +13,7 @@ import { SingleEstimateResult, } from "./data.js"; import { ResultsTable, Row } from "./resultsTable.js"; -import { - Axis, - HideTooltip, - PlotItem, - ScatterChart, - ScatterSeries, - SelectPoint, -} from "./scatterChart.js"; +import { Axis, PlotItem, ScatterChart, ScatterSeries } from "./scatterChart.js"; const columnNames = [ "Run name", @@ -77,7 +70,6 @@ function reDataToRow(input: ReData, color: string): Row { }, data.physicalCounts.rqops, data.physicalCounts.physicalQubits, - data.new ? "New" : "Cached", ], color: color, }; @@ -118,6 +110,11 @@ function reDataToRowScatter(data: ReData, color: string): ScatterSeries { } function createRunNames(estimatesData: ReData[]): string[] { + // If there's only 1 entry, use the shared run name + if (estimatesData.length === 1) { + return [estimatesData[0].jobParams.sharedRunName]; + } + const fields: string[][] = []; estimatesData.forEach(() => { @@ -178,6 +175,7 @@ export function EstimatesOverview(props: { setEstimate: (estimate: SingleEstimateResult | null) => void; }) { const [selectedRow, setSelectedRow] = useState(null); + const [selectedPoint, setSelectedPoint] = useState<[number, number]>(); const runNameRenderingError = props.runNames != null && @@ -198,24 +196,24 @@ export function EstimatesOverview(props: { }); function onPointSelected(seriesIndex: number, pointIndex: number): void { + if (seriesIndex < 0) { + // Point was deselected + onRowSelected(""); + return; + } + const data = props.estimatesData[seriesIndex]; props.setEstimate(CreateSingleEstimateResult(data, pointIndex)); const rowId = props.estimatesData[seriesIndex].jobParams.runName; setSelectedRow(rowId); + setSelectedPoint([seriesIndex, pointIndex]); } - function onRowSelected(rowId: string, ev?: Event) { + function onRowSelected(rowId: string) { setSelectedRow(rowId); - // On any selection, clear the "new" flag on all rows. This ensures that - // new rows do not steal focus from the user selected row. - props.estimatesData.forEach((data) => (data.new = false)); - - const root = findRoot(ev); - if (root) { - HideTooltip(root); - } if (!rowId) { props.setEstimate(null); + setSelectedPoint(undefined); } else { const index = props.estimatesData.findIndex( (data) => data.jobParams.runName === rowId, @@ -223,24 +221,15 @@ export function EstimatesOverview(props: { if (index == -1) { props.setEstimate(null); + setSelectedPoint(undefined); } else { const estimateFound = props.estimatesData[index]; + setSelectedPoint([index, 0]); props.setEstimate(CreateSingleEstimateResult(estimateFound, 0)); - if (root) { - SelectPoint(index, 0, root); - } } } } - function findRoot(ev?: Event): Element | undefined { - return ( - (ev?.currentTarget as Element | undefined)?.closest( - ".qs-estimatesOverview", - ) ?? undefined - ); - } - const colorRenderingError = props.colors != null && props.colors.length > 0 && @@ -253,36 +242,32 @@ export function EstimatesOverview(props: { ? props.colors : ColorMap(props.estimatesData.length); - if (props.isSimplifiedView) { + function getResultTable() { return ( -
- {runNameRenderingError != "" && ( -
{runNameRenderingError}
+ + reDataToRow(dataItem, colormap[index]), )} - {colorRenderingError != "" && ( -
{colorRenderingError}
+ initialColumns={initialColumns} + selectedRow={selectedRow} + onRowSelected={onRowSelected} + onRowDeleted={props.onRowDeleted} + /> + ); + } + + function getScatterChart() { + return ( + + reDataToRowScatter(dataItem, colormap[index]), )} - - reDataToRow(dataItem, colormap[index]), - )} - initialColumns={initialColumns} - // should be able to deselect rows for making screenshots - ensureSelected={false} - onRowDeleted={props.onRowDeleted} - selectedRow={selectedRow} - onRowSelected={onRowSelected} - /> - - reDataToRowScatter(dataItem, colormap[index]), - )} - onPointSelected={onPointSelected} - /> -
+ onPointSelected={onPointSelected} + selectedPoint={selectedPoint} + /> ); } @@ -294,36 +279,27 @@ export function EstimatesOverview(props: { {colorRenderingError != "" && (
{colorRenderingError}
)} -
- - Results - - - reDataToRow(dataItem, colormap[index]), - )} - initialColumns={initialColumns} - selectedRow={selectedRow} - onRowSelected={onRowSelected} - // should be able to deselect rows for making screenshots - ensureSelected={false} - onRowDeleted={props.onRowDeleted} - /> -
-
- - Space-time diagram - - - reDataToRowScatter(dataItem, colormap[index]), - )} - onPointSelected={onPointSelected} - /> -
+ {!props.isSimplifiedView ? ( + <> +
+ + Results + + {getResultTable()} +
+
+ + Space-time diagram + + {getScatterChart()} +
+ + ) : ( + <> + {getResultTable()} + {getScatterChart()} + + )} ); } diff --git a/npm/ux/qsharp-ux.css b/npm/ux/qsharp-ux.css index c54a144bc4..b04acbf114 100644 --- a/npm/ux/qsharp-ux.css +++ b/npm/ux/qsharp-ux.css @@ -28,7 +28,10 @@ modern-normalize (see https://mattbrictson.com/blog/css-normalize-and-reset for /* TODO: Use the qs- prefixes and apply consistently */ :root { --heading-background: #262679; - --main-background: var(--vscode-editor-background, #ececf0); + --main-background: var( + --vscode-editor-background, + var(--jp-layout-color0, #ececf0) + ); --main-color: var( --vscode-editor-foreground, var(--jp-widgets-color, #202020) @@ -529,6 +532,7 @@ html { .qs-resultsTable-sortedTable { border-collapse: collapse; margin: 12px 0px; + outline: none; } .qs-resultsTable-sortedTable th, @@ -548,11 +552,11 @@ html { } .qs-resultsTable-sortedTable tbody tr:hover { - background: var(--vscode-list-hoverBackground); + background: var(--vscode-list-hoverBackground, var(--js-layout-color2)); } .qs-resultsTable-sortedTableSelectedRow td { - background: var(--vscode-button-background); + background: var(--vscode-button-background, var(--jp-layout-color3)); color: var(--vscode-button-foreground); font-weight: 600; } @@ -565,7 +569,7 @@ html { .qs-resultsTable-showColumnMenu { display: block; position: absolute; - background-color: var(--vscode-menu-background); + background-color: var(--vscode-menu-background, var(--main-background)); border: 1px solid #8888; padding: 4px; z-index: 100; @@ -574,7 +578,7 @@ html { .qs-resultsTable-menuItem { cursor: pointer; - background-color: var(--vscode-list-hoverBackground); + background-color: var(--vscode-list-hoverBackground, var(--jp-layout-color1)); padding: 4px; margin: 2px; font-size: 14px; @@ -582,7 +586,7 @@ html { } .qs-resultsTable-menuItem:hover { - background-color: var(--vscode-menu-border); + background-color: var(--vscode-menu-border, var(--jp-layout-color2)); } .qs-resultsTable-columnSelected { @@ -590,8 +594,8 @@ html { padding: 4px; font-weight: 400; font-size: 14px; - background-color: var(--vscode-button-background); - color: var(--vscode-button-foreground); + background-color: var(--vscode-button-background, var(--jp-brand-color1)); + color: var(--vscode-button-foreground, var(--jp-ui-inverse-font-color0)); margin: 2px; border-radius: 3px; } @@ -616,10 +620,6 @@ html { animation: codicon-spin 1.5s steps(45) infinite; } -.qs-scatterChart { - font-size: 14px; -} - .qs-scatterChart-x-axisTitle, .qs-scatterChart-y-axisTitle { text-anchor: middle; @@ -637,14 +637,14 @@ html { stroke-width: 4px; } -.qs-scatterChart-point-selected { - r: 6px; - stroke-width: 3px; +.qs-scatterChart-hover:hover { + r: 4px; + stroke-width: 4px; fill: white; } -.qs-scatterChart-point:hover { - r: 4px; +.qs-scatterChart-point-selected { + r: 8px; stroke-width: 4px; fill: white; } @@ -654,19 +654,29 @@ html { fill: var(--main-color); } -.qs-scatterChart-tooltip-text { - text-anchor: left; - fill: black; +.qs-scatterChart-tooltip, +.qs-scatterChart-selectedInfo { + position: absolute; + visibility: hidden; + background: var(--main-background); + color: var(--main-color); + border: var(--border-color) 1px solid; + padding: 4px; } -.qs-scatterChart-tooltip-rect { - fill: white; - stroke: black; - stroke-width: 1px; - height: 22px; +/* This rule adds the little triange pointer at the top */ +.qs-scatterChart-tooltip::after, +.qs-scatterChart-selectedInfo::after { + content: ""; + position: absolute; + bottom: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: transparent transparent var(--border-color) transparent; } -.qs-scatterChart-line, .qs-scatterChart-axis, .qs-scatterChart-tick-line { stroke: var(--border-color); diff --git a/npm/ux/resultsTable.tsx b/npm/ux/resultsTable.tsx index e42aab6fb3..db8051fec2 100644 --- a/npm/ux/resultsTable.tsx +++ b/npm/ux/resultsTable.tsx @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useRef, useState } from "preact/hooks"; +import { useRef, useState, useEffect } from "preact/hooks"; export type CellValue = string | number | { value: string; sortBy: number }; export type Row = { @@ -14,10 +14,9 @@ export function ResultsTable(props: { columnNames: string[]; rows: Row[]; initialColumns: number[]; - ensureSelected: boolean; onRowDeleted(rowId: string): void; - selectedRow: string | null; // type selected to confirm with the useState pattern on the parent component - onRowSelected(rowId: string, ev?: Event): void; + selectedRow: string | null; + onRowSelected(rowId: string): void; }) { const [showColumns, setShowColumns] = useState(props.initialColumns); const [sortColumn, setSortColumn] = useState<{ @@ -28,26 +27,9 @@ export function ResultsTable(props: { const [showColumnMenu, setShowColumnMenu] = useState(false); const [showRowMenu, setShowRowMenu] = useState(""); - // Find the first row that is new in the current sort order - const newest = getSortedRows(props.rows).find( - (row) => (row.cells[row.cells.length - 1] as string) === "New", - ); - - // Select the first of the newest rows, otherwise preserve the existing selection - if (newest && props.ensureSelected) { - const rowId = newest.cells[0].toString(); - onRowSelected(rowId, undefined); - } else if ( - !props.selectedRow && - props.ensureSelected && - props.rows.length > 0 - ) { - const rowId = props.rows[0].cells[0].toString(); - onRowSelected(rowId, undefined); - } - // Use to track the column being dragged const draggingCol = useRef(""); + const columnMenu = useRef(null); /* Note: Drag and drop events can occur faster than preact reconciles state. @@ -83,8 +65,8 @@ export function ResultsTable(props: { } } - function onRowSelected(rowId: string, ev?: Event) { - props.onRowSelected(rowId, ev); + function onRowSelected(rowId: string) { + props.onRowSelected(rowId); } function onDragOver(ev: DragEvent) { @@ -200,11 +182,9 @@ export function ResultsTable(props: { } } - function onRowClicked(rowId: string, ev: Event) { - if (props.selectedRow === rowId && props.ensureSelected) return; - + function onRowClicked(rowId: string) { const newSelectedRow = props.selectedRow === rowId ? "" : rowId; - onRowSelected(newSelectedRow, ev); + onRowSelected(newSelectedRow); } function onClickRowMenu(ev: MouseEvent, rowid: string) { @@ -254,17 +234,59 @@ export function ResultsTable(props: { // Clear out any menus or selections for the row if needed setShowRowMenu(""); if (props.selectedRow === rowId) { - onRowSelected("", e); + onRowSelected(""); } props.onRowDeleted(rowId); } + function onKeyDown(ev: KeyboardEvent) { + if (!props.selectedRow) return; + const sortedRowNames = getSortedRows(props.rows).map((row) => + row.cells[0].toString(), + ); + const currIndex = sortedRowNames.indexOf(props.selectedRow); + + switch (ev.code) { + case "ArrowDown": + if (currIndex < sortedRowNames.length - 1) { + ev.preventDefault(); + props.onRowSelected(sortedRowNames[currIndex + 1]); + } + break; + case "ArrowUp": + if (currIndex > 0) { + ev.preventDefault(); + props.onRowSelected(sortedRowNames[currIndex - 1]); + } + break; + default: + // Not of interest + } + } + + useEffect(() => { + // Post rendering, if the column menu is displayed, then ensure it + // has focus so that clicking anywhere outside of it caused the blur + // event that closes it. + if (showColumnMenu && columnMenu.current) { + columnMenu.current.focus(); + } + }); + return ( - +
-
+
setShowColumnMenu(false)} + >
onRowClicked(rowId, e)} + onClick={() => onRowClicked(rowId)} data-rowid={rowId} class={ rowId === props.selectedRow diff --git a/npm/ux/scatterChart.tsx b/npm/ux/scatterChart.tsx index c576a4a38a..0ca5672d0f 100644 --- a/npm/ux/scatterChart.tsx +++ b/npm/ux/scatterChart.tsx @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { CreateIntegerTicks, CreateTimeTicks, Tick } from "../src/ux/ticks.js"; -import { useEffect, useRef } from "preact/hooks"; +import { useRef, useEffect } from "preact/hooks"; +import * as utils from "../src/utils.js"; export type ScatterSeries = { color: string; @@ -25,157 +25,41 @@ type Range = { max: number; }; -export function HideTooltip(root: Element) { - // could be called extenally with a root out of the chart. - const chart = - root.id == "scatterChart" ? root : root.querySelector("#scatterChart"); - - chart - ?.querySelector("#tooltip-selected") - ?.setAttribute("visibility", "hidden"); -} - -function drawTooltip( - target: SVGCircleElement, - root: Element, - clicked: boolean = false, -) { - const xAttr = target.getAttribute("cx"); - const x = xAttr ? parseInt(xAttr) : 0; - const yAttr = target.getAttribute("cy"); - const y = yAttr ? parseInt(yAttr) : -0; - const text = target.getAttribute("data-label"); - const tooltipTextLeftPadding = 5; - const tooltipRectanglePaddingHeight = 10; - const tooltipTextPaddingHeight = 25; - const tooltipId = clicked ? "#tooltip-selected" : "#tooltip-hover"; - const tooltip = root.querySelector(tooltipId); - const tooltipRect = tooltip?.querySelector("#tooltipRect"); - const tooltipText = tooltip?.querySelector( - "#tooltipText", - ) as unknown as SVGTextElement; - - if (tooltipText) { - tooltipText.setAttribute("x", (x + tooltipTextLeftPadding).toString()); - tooltipText.setAttribute("y", (y + tooltipTextPaddingHeight).toString()); - tooltipText.textContent = text; - } - if (tooltipRect && tooltipText) { - const box = tooltipText.getBBox(); - const textWidth = box.width; - tooltipRect.setAttribute( - "width", - (textWidth + 2 * tooltipTextLeftPadding).toString(), - ); - tooltipRect.setAttribute("x", x.toString()); - tooltipRect.setAttribute( - "y", - (y + tooltipRectanglePaddingHeight).toString(), - ); - } - if (tooltip) { - tooltip.setAttribute("visibility", "visible"); - tooltip.setAttribute("clicked", clicked.toString()); - } -} - -function deselectPoint(root: Element) { - if (root.getAttribute("selectedPoint")) { - const point = root.querySelector( - ("#" + root.getAttribute("selectedPoint")) as string, - ); - if (point) { - point.classList.remove("qs-scatterChart-point-selected"); - } - } -} - -export function SelectPoint( - seriesIndex: number, - pointIndex: number, - root: Element, -) { - // could be called extenally with a root out of the chart. - const chart = - root.id == "scatterChart" ? root : root.querySelector("#scatterChart"); - if (chart == null) { - return; - } - deselectPoint(chart); - const point = chart.querySelector(`#point-${seriesIndex}-${pointIndex}`); - if (point) { - point.classList.add("qs-scatterChart-point-selected"); - chart.setAttribute("selectedPoint", point.id); - drawTooltip(point as unknown as SVGCircleElement, root, true); - } -} - export function ScatterChart(props: { data: ScatterSeries[]; xAxis: Axis; yAxis: Axis; onPointSelected(seriesIndex: number, pointIndex: number): void; + selectedPoint?: [number, number]; }) { - const data = props.data; - - function findMinMaxSingle( - series: ScatterSeries, - ): [number, number, number, number] { - const xs = series.items.map((item) => item.x); - const ys = series.items.map((item) => item.y); - const minX = Math.min(...xs); - const maxX = Math.max(...xs); - const minY = Math.min(...ys); - const maxY = Math.max(...ys); - return [minX, maxX, minY, maxY]; - } - - function findMinMaxAll( - series: ScatterSeries[], - ): [number, number, number, number] { - const minMax = series.map(findMinMaxSingle); - const minX = Math.min(...minMax.map((x) => x[0])); - const maxX = Math.max(...minMax.map((x) => x[1])); - const minY = Math.min(...minMax.map((x) => x[2])); - const maxY = Math.max(...minMax.map((x) => x[3])); - return [minX, maxX, minY, maxY]; - } + const selectedTooltipDiv = useRef(null); - const [minX, maxX, minY, maxY] = findMinMaxAll(data); + const { rangeX, rangeY } = utils.getRanges(props.data, 2 /* coefficient */); - const rangeCoefficient = 2; - const rangeX: Range = { - min: minX / rangeCoefficient, - max: maxX * rangeCoefficient, - }; - const rangeY: Range = { - min: minY / rangeCoefficient, - max: maxY * rangeCoefficient, - }; - - function createAxisTicks(range: Range, isTime: boolean): Tick[] { - if (isTime) { - return CreateTimeTicks(range.min, range.max); - } else { - return CreateIntegerTicks(range.min, range.max); - } + function createAxisTicks(range: Range, isTime: boolean): utils.Tick[] { + return isTime + ? utils.CreateTimeTicks(range.min, range.max) + : utils.CreateIntegerTicks(range.min, range.max); } const xTicks = createAxisTicks(rangeX, props.xAxis.isTime); const yTicks = createAxisTicks(rangeY, props.yAxis.isTime); - function coordinateToSvgLogarithmic( - value: number, - range: Range, - size: number, - ): number { + function coordinateToLogarithmic(value: number, range: Range): number { return ( - ((Math.log(value) - Math.log(range.min)) / - (Math.log(range.max) - Math.log(range.min))) * - size + (Math.log(value) - Math.log(range.min)) / + (Math.log(range.max) - Math.log(range.min)) ); } + function toLogX(val: number): number { + return coordinateToLogarithmic(val, rangeX) * plotAreaWidth; + } + + function toLogY(val: number): number { + return -coordinateToLogarithmic(val, rangeY) * plotAreaHeight; + } + const yAxisTitleWidth = 20; const yAxisTickCaptionMaxWidth = 100; const axisTickLength = 5; @@ -201,199 +85,216 @@ export function ScatterChart(props: { const plotAreaWidth = svgWidth - xLeftMargin - xRightMargin; const plotAreaHeight = svgHeight - yMargin; - const viewBox = `${svgXMin - svgViewBoxWidthPadding} ${ - -plotAreaHeight - svgViewBoxHeightPadding - } ${svgWidth + svgViewBoxWidthPadding} ${ - svgHeight + svgViewBoxHeightPadding - }`; + const viewBox = `${svgXMin} ${-plotAreaHeight - svgViewBoxHeightPadding} ${ + svgWidth + svgViewBoxWidthPadding + } ${svgHeight + svgViewBoxHeightPadding}`; const yAxisTextPaddingFromTicks = 5; const yAxisTextYPadding = 4; - function trySetAttribute(element: Element, attribute: string, value: string) { - if (element.getAttribute(attribute) !== value) { - element.setAttribute(attribute, value); - } + function renderTooltip( + topDiv: HTMLDivElement, + point: SVGCircleElement, + tooltip: HTMLDivElement, + ) { + const label = point.getAttribute("data-label"); + tooltip.textContent = label; + const halfWidth = tooltip.offsetWidth / 2; + const pointRect = point.getBoundingClientRect(); + const centerY = (pointRect.top + pointRect.bottom) / 2; + const centerX = (pointRect.left + pointRect.right) / 2; + const divRect = topDiv.getBoundingClientRect(); + tooltip.style.left = `${centerX - divRect.left - halfWidth}px`; + tooltip.style.top = `${centerY - divRect.top + 12}px`; + tooltip.style.visibility = "visible"; } - function hideHoverTooltip() { - const tooltip = chart?.querySelector("#tooltip-hover"); - - if (tooltip) { - if (tooltip.getAttribute("clicked") === "false") { - tooltip.setAttribute("visibility", "hidden"); - } + function onPointMouseEvent(ev: MouseEvent, eventType: string) { + // Ensure we have a point as the target + if (!(ev.target instanceof SVGCircleElement)) return; + const target = ev.target as SVGCircleElement; + if (!target.classList.contains("qs-scatterChart-point")) return; + + // Get the div enclosing the chart, and the popup child of it. + const topDiv = target.closest("div") as HTMLDivElement; + const popup = topDiv.querySelector( + ".qs-scatterChart-tooltip", + ) as HTMLDivElement; + + switch (eventType) { + case "over": + { + renderTooltip(topDiv, target, popup); + } + break; + case "out": + popup.style.visibility = "hidden"; + break; + case "click": + { + if (target.classList.contains("qs-scatterChart-point-selected")) { + // Clicked on the already selected point, so delete the point/row + props.onPointSelected(-1, 0); + } else { + const index = JSON.parse(target.getAttribute("data-index")!); + props.onPointSelected(index[0], index[1]); + } + } + break; + default: + console.error("Unknown event type: ", eventType); } } - const scatterChartContainerRef = useRef(null); - let chart: Element | null = null; - - function updateCoordinates() { - if (!chart) { - return; - } - - chart.querySelectorAll("[x-data-value]").forEach((element) => { - const value = Number(element.getAttribute("x-data-value")); - const x = coordinateToSvgLogarithmic(value, rangeX, plotAreaWidth); - const padding = element.getAttribute("x-data-padding"); - const value_with_padding = (x + Number(padding)).toString(); - trySetAttribute(element, "x", value_with_padding); - trySetAttribute(element, "x1", value_with_padding); - trySetAttribute(element, "x2", value_with_padding); - trySetAttribute(element, "cx", value_with_padding); - }); - - chart.querySelectorAll("[y-data-value]").forEach((element) => { - const value = Number(element.getAttribute("y-data-value")); - const y = -coordinateToSvgLogarithmic(value, rangeY, plotAreaHeight); - const padding = element.getAttribute("y-data-padding"); - const value_with_padding = (y + Number(padding)).toString(); - trySetAttribute(element, "y", value_with_padding); - trySetAttribute(element, "y1", value_with_padding); - trySetAttribute(element, "y2", value_with_padding); - trySetAttribute(element, "cy", value_with_padding); - }); + function getSelectedPointData() { + if (!props.selectedPoint) return null; + const series = props.data[props.selectedPoint[0]]; + const item = series.items[props.selectedPoint[1]]; + return { ...item, color: series.color }; } + const selectedPoint = getSelectedPointData(); + // Need to render first to get the element layout to position the tooltip useEffect(() => { - chart = scatterChartContainerRef.current as Element | null; - updateCoordinates(); + if (!selectedTooltipDiv.current) return; + if (!props.selectedPoint) { + selectedTooltipDiv.current.style.visibility = "hidden"; + } else { + // Locate the selected point and put the tooltip under it + const topDiv = selectedTooltipDiv.current.parentElement as HTMLDivElement; + const selectedPoint = topDiv?.querySelector( + ".qs-scatterChart-point-selected", + ) as SVGCircleElement; + if (!selectedPoint) return; + renderTooltip(topDiv, selectedPoint, selectedTooltipDiv.current); + } }); + // The mouse events (over, out, and click) bubble, so put the hanlders on the + // SVG element and check the target element in the handler. return ( -
-
- + onPointMouseEvent(ev, "over")} + onMouseOut={(ev) => onPointMouseEvent(ev, "out")} + onClick={(ev) => onPointMouseEvent(ev, "click")} + > + + + {xTicks.map((tick) => { + return ( + <> + + + {tick.label} + + + ); + })} + + + + {yTicks.map((tick) => { + return ( + <> + + + {tick.label} + + + ); + })} + + - - - {xTicks.map((tick) => { - return ( - - - - {tick.label} - - - ); - })} + {props.xAxis.label} (logarithmic) + - + + {props.yAxis.label} (logarithmic) + - {yTicks.map((tick) => { - return ( - - + Created with Azure Quantum Resource Estimator + + + {props.data.map((series, seriesIdx) => { + return series.items.map((plot, plotIdx) => { + return ( + - - {tick.label} - - - ); + ); + }); })} - - - {props.xAxis.label} (logarithmic) - - - - {props.yAxis.label} (logarithmic) - - - - Created with Azure Quantum Resource Estimator - - - {data.map((data, seriesIndex) => { - return data.items.map((item, pointIndex) => { - return ( - { - const circle = e.currentTarget; - - if (chart) { - drawTooltip(circle, chart, false); - } - circle?.parentNode?.appendChild(circle); // move the hovered cicrle up on the rendering stack - }} - onClick={() => { - if (chart) { - SelectPoint(seriesIndex, pointIndex, chart); - } - props.onPointSelected(seriesIndex, pointIndex); - }} - onMouseOut={() => hideHoverTooltip()} - /> - ); - }); - })} - - - - - - - - - - -
+ + { + // Render the selected point last, so it's always on top of the others + selectedPoint ? ( + + ) : null + } + +
+
); } diff --git a/npm/ux/tsconfig.json b/npm/ux/tsconfig.json index 0fe35e1ef1..d03570dfd6 100644 --- a/npm/ux/tsconfig.json +++ b/npm/ux/tsconfig.json @@ -4,7 +4,6 @@ "target": "ES2020", "noEmit": true, "lib": ["DOM", "ES2020"], - "rootDir": ".", "strict": true /* enable all strict type-checking options */, "jsx": "react-jsx", "jsxImportSource": "preact", diff --git a/vscode/src/webview/webview.tsx b/vscode/src/webview/webview.tsx index 394af61b58..4830dcdd8c 100644 --- a/vscode/src/webview/webview.tsx +++ b/vscode/src/webview/webview.tsx @@ -83,10 +83,6 @@ function onMessage(event: any) { }; // Copy over any existing estimates if ((state as EstimatesState).estimatesData?.estimates) { - // Clear the new flag on any existing estimates - (state as EstimatesState).estimatesData.estimates.map( - (estimate) => (estimate.new = false), - ); newState.estimatesData.estimates.push( ...(state as EstimatesState).estimatesData.estimates, ); @@ -94,11 +90,8 @@ function onMessage(event: any) { // Append any new estimates if (message.estimates) { if (Array.isArray(message.estimates)) { - // Mark all the new estimates - message.estimates.map((estimate: ReData) => (estimate.new = true)); newState.estimatesData.estimates.push(...message.estimates); } else { - message.estimates.new = true; newState.estimatesData.estimates.push(message.estimates); } }