microsoft/qdk

Public

mirrored from https://github.com/microsoft/qdkAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
iadavis/pipeline-issue-debugging

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

source/npm/qsharp/ux/histogram.tsx

512lines · modecode

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4import { useEffect, useRef, useState } from "preact/hooks";
5
6const menuItems = [
7 {
8 category: "itemCount",
9 options: ["Show all", "Top 10", "Top 25"],
10 },
11 {
12 category: "sortOrder",
13 options: ["Sort a-z", "High to low", "Low to high"],
14 },
15 {
16 category: "labels",
17 options: ["Raw labels", "Ket labels", "No labels"],
18 },
19];
20const maxMenuOptions = 3;
21
22function getDefaultMenuSelection(
23 labels?: "raw" | "kets" | "none",
24 items?: "all" | "top-10" | "top-25",
25 sort?: "a-to-z" | "high-to-low" | "low-to-high",
26): {
27 [idx: string]: number;
28} {
29 const selection = {
30 itemCount: 0,
31 sortOrder: 0,
32 labels: 0,
33 };
34 switch (items) {
35 case "top-10":
36 selection["itemCount"] = 1;
37 break;
38 case "top-25":
39 selection["itemCount"] = 2;
40 break;
41 default:
42 selection["itemCount"] = 0;
43 break;
44 }
45 switch (sort) {
46 case "high-to-low":
47 selection["sortOrder"] = 1;
48 break;
49 case "low-to-high":
50 selection["sortOrder"] = 2;
51 break;
52 default:
53 selection["sortOrder"] = 0;
54 break;
55 }
56 switch (labels) {
57 case "kets":
58 selection["labels"] = 1;
59 break;
60 case "none":
61 selection["labels"] = 2;
62 break;
63 default:
64 selection["labels"] = 0;
65 break;
66 }
67 return selection;
68}
69
70const reKetResult =
71 /^\[(?:(Zero|One|Loss|0|1|2|-), *)*(Zero|One|Loss|0|1|2|-)\]$/;
72function resultToKet(result: string): string {
73 if (typeof result !== "string") return "ERROR";
74
75 if (reKetResult.test(result)) {
76 // The result fits our expected pattern, so we can convert it to a ket. If not, just return the raw result.
77 const matches = result.match(/(One|Zero|Loss|0|1|2|-)/g);
78 let ket = "|";
79 matches?.forEach(
80 (digit) =>
81 (ket +=
82 digit == "One" || digit == "1"
83 ? "1"
84 : digit == "Zero" || digit == "0"
85 ? "0"
86 : "-"),
87 );
88 ket += "⟩";
89 return ket;
90 } else {
91 return result;
92 }
93}
94
95export function Histogram(props: {
96 shotCount: number;
97 data: Map<string, number>;
98 filter: string;
99 onFilter: (filter: string) => void;
100 shotsHeader: boolean;
101 labels?: "raw" | "kets" | "none";
102 items?: "all" | "top-10" | "top-25";
103 sort?: "a-to-z" | "high-to-low" | "low-to-high";
104}) {
105 const [hoverLabel, setHoverLabel] = useState("");
106 const [scale, setScale] = useState({ zoom: 1.0, offset: 1.0 });
107 const [menuSelection, setMenuSelection] = useState(() => {
108 return getDefaultMenuSelection(props.labels, props.items, props.sort);
109 });
110
111 useEffect(() => {
112 setMenuSelection(
113 getDefaultMenuSelection(props.labels, props.items, props.sort),
114 );
115 }, [props.labels, props.items, props.sort]);
116
117 const gMenu = useRef<SVGGElement>(null);
118 const gInfo = useRef<SVGGElement>(null);
119
120 let maxItemsToShow = 0; // All
121 switch (menuSelection["itemCount"]) {
122 case 1:
123 maxItemsToShow = 10;
124 break;
125 case 2:
126 maxItemsToShow = 25;
127 break;
128 }
129 const showKetLabels = menuSelection["labels"] === 1;
130
131 const bucketArray = [...props.data];
132
133 // Calculate bucket percentages before truncating for display
134 let totalAllBuckets = 0;
135 let sizeBiggestBucket = 0;
136 bucketArray.forEach((x) => {
137 totalAllBuckets += x[1];
138 sizeBiggestBucket = Math.max(x[1], sizeBiggestBucket);
139 });
140
141 let histogramLabel = `${bucketArray.length} unique results`;
142 if (maxItemsToShow > 0) {
143 // Sort from high to low then take the first n
144 bucketArray.sort((a, b) => (a[1] < b[1] ? 1 : -1));
145 if (bucketArray.length > maxItemsToShow) {
146 histogramLabel = `Top ${maxItemsToShow} of ${histogramLabel}`;
147 bucketArray.length = maxItemsToShow;
148 }
149 }
150 if (props.filter) {
151 histogramLabel += `. Shot filter: ${
152 showKetLabels ? resultToKet(props.filter) : props.filter
153 }`;
154 }
155
156 bucketArray.sort((a, b) => {
157 const a_label = showKetLabels ? resultToKet(a[0]) : a[0];
158 const b_label = showKetLabels ? resultToKet(b[0]) : b[0];
159
160 // If they can be converted to numbers, then sort as numbers, else lexically
161 const ax = Number(a_label);
162 const bx = Number(b_label);
163 switch (menuSelection["sortOrder"]) {
164 case 1: // high-to-low
165 return a[1] < b[1] ? 1 : -1;
166 break;
167 case 2: // low-to-high
168 return a[1] > b[1] ? 1 : -1;
169 break;
170 default: // a-z
171 if (!isNaN(ax) && !isNaN(bx)) return ax < bx ? -1 : 1;
172 return a_label < b_label ? -1 : 1;
173 break;
174 }
175 });
176
177 function onMouseOverRect(evt: MouseEvent) {
178 const target = evt.target as SVGRectElement;
179 const title = target.querySelector("title")?.textContent;
180 setHoverLabel(title || "");
181 }
182
183 function onMouseOutRect() {
184 setHoverLabel("");
185 }
186
187 function onClickRect(evt: MouseEvent) {
188 const targetElem = evt.target as SVGRectElement;
189 const rawLabel = targetElem.getAttribute("data-raw-label");
190
191 if (rawLabel === props.filter) {
192 // Clicked the already selected bar. Clear the filter
193 props.onFilter("");
194 } else {
195 props.onFilter(rawLabel || "");
196 }
197 }
198
199 function toggleMenu() {
200 if (!gMenu.current) return;
201 if (gMenu.current.style.display === "inline") {
202 gMenu.current.style.display = "none";
203 } else {
204 gMenu.current.style.display = "inline";
205 if (gInfo.current) gInfo.current.style.display = "none";
206 }
207 }
208
209 function menuClicked(category: string, idx: number) {
210 if (!gMenu.current) return;
211 const newMenuSelection = { ...menuSelection };
212 newMenuSelection[category] = idx;
213 setMenuSelection(newMenuSelection);
214 if (category === "itemCount") {
215 setScale({ zoom: 1, offset: 1 });
216 }
217 gMenu.current.style.display = "none";
218 }
219
220 function toggleInfo() {
221 if (!gInfo.current) return;
222
223 gInfo.current.style.display =
224 gInfo.current.style.display === "inline" ? "none" : "inline";
225 }
226
227 // Each menu item has a width of 32px and a height of 10px
228 // Menu items are 38px apart on the x-axis, and 11px on the y-axis.
229 const menuItemWidth = 38;
230 const menuItemHeight = 11;
231 const menuBoxWidth = menuItems.length * menuItemWidth - 2;
232 const menuBoxHeight = maxMenuOptions * menuItemHeight + 3;
233
234 const barAreaWidth = 163;
235 const barAreaHeight = 72;
236 const fontOffset = 1.2;
237
238 // Scale the below for when zoomed
239 const barBoxWidth = (barAreaWidth * scale.zoom) / bucketArray.length;
240 const barPaddingPercent = 0.1; // 10%
241 const barPaddingSize = barBoxWidth * barPaddingPercent;
242 const barFillWidth = barBoxWidth - 2 * barPaddingSize;
243 const showLabels = barBoxWidth > 5 && menuSelection["labels"] !== 2;
244
245 function onWheel(e: WheelEvent): void {
246 // Ctrl+scroll is the event sent by pinch-to-zoom on a trackpad. Shift+scroll is common for
247 // panning horizontally. See https://danburzo.ro/dom-gestures/ for the messy details.
248 if (!e.ctrlKey && !e.shiftKey) return;
249
250 // When using a mouse wheel, the deltaY is the scroll amount, but if the shift key is pressed
251 // this swaps and deltaX is the scroll amount. The swap doesn't happen for trackpad scrolling.
252 // To complicate matters more, on the trackpad sometimes both deltaX and deltaY have a value.
253 // So, if the shift key is pressed and deltaY is 0, then assume mouse wheel and use deltaX.
254 let delta = e.shiftKey && !e.deltaY ? e.deltaX : e.deltaY;
255
256 // Scrolling with the wheel can result in really large deltas, so we need to cap them.
257 if (Math.abs(delta) > 20) {
258 delta = Math.sign(delta) * 20;
259 }
260
261 e.preventDefault();
262
263 // currentTarget is the element the listener is attached to, the main svg
264 // element in this case.
265 const svgElem = e.currentTarget as SVGSVGElement;
266
267 // Below gets the mouse location in the svg element coordinates. This stays
268 // consistent while the scroll is occurring (i.e. it is the point the mouse
269 // was at when scrolling started).
270 const mousePoint = new DOMPoint(e.clientX, e.clientY).matrixTransform(
271 svgElem.getScreenCTM()?.inverse(),
272 );
273
274 /*
275 While zooming, we want is to track the point the mouse is at when scrolling, and pin
276 that location on the screen. That means adjusting the scroll offset.
277
278 SVG translation is used to pan left and right, but zooming is done manually (making the
279 bars wider or thinner) to keep the fonts from getting stretched, which occurs with scaling.
280
281 deltaX and deltaY do not accumulate across events, they are a new delta each time.
282 */
283
284 let newScrollOffset = scale.offset;
285 let newZoom = scale.zoom;
286
287 if (!e.shiftKey) {
288 // *** Zooming ***
289 newZoom = scale.zoom - delta * 0.05;
290 newZoom = Math.min(Math.max(1, newZoom), 50);
291
292 // On zooming in, need to shift left to maintain mouse point, and vice verca.
293 const oldChartWidth = barAreaWidth * scale.zoom;
294 const mousePointOnChart = 0 - scale.offset + mousePoint.x;
295 const percentRightOnChart = mousePointOnChart / oldChartWidth;
296 const chartWidthGrowth =
297 newZoom * barAreaWidth - scale.zoom * barAreaWidth;
298 const shiftLeftAdjust = percentRightOnChart * chartWidthGrowth;
299 newScrollOffset = scale.offset - shiftLeftAdjust;
300 } else {
301 // *** Panning ***
302 newScrollOffset -= delta;
303 }
304
305 // Don't allow offset > 1 (scrolls the first bar right of the left edge of the area)
306 // Don't allow for less than 0 - barwidths + screen width (scrolls last bar left of the right edge)
307 const maxScrollRight = 1 - (barAreaWidth * newZoom - barAreaWidth);
308 const boundScrollOffset = Math.min(
309 Math.max(newScrollOffset, maxScrollRight),
310 1,
311 );
312
313 setScale({ zoom: newZoom, offset: boundScrollOffset });
314 }
315
316 const label_class = showKetLabels ? "bar-label bar-label-ket" : "bar-label";
317
318 return (
319 <>
320 {props.shotsHeader ? (
321 <h4 style="margin: 8px 0px">Total shots: {props.shotCount}</h4>
322 ) : null}
323 <svg class="histogram" viewBox="0 0 165 100" onWheel={onWheel}>
324 <g transform={`translate(${scale.offset},4)`}>
325 {bucketArray.map((entry, idx) => {
326 const label = showKetLabels ? resultToKet(entry[0]) : entry[0];
327
328 const height = barAreaHeight * (entry[1] / sizeBiggestBucket);
329 const x = barBoxWidth * idx + barPaddingSize;
330 const labelX = barBoxWidth * idx + barBoxWidth / 2 - fontOffset;
331 const y = barAreaHeight + 15 - height;
332 const barLabel =
333 props.shotCount == 0
334 ? `${entry[1]}`
335 : `${label} at ${((entry[1] / totalAllBuckets) * 100).toFixed(
336 2,
337 )}%`;
338 let barClass = "bar";
339
340 if (entry[0] === props.filter) {
341 barClass += " bar-selected";
342 }
343
344 return (
345 <>
346 <rect
347 class={barClass}
348 x={x}
349 y={y}
350 width={barFillWidth}
351 height={height}
352 onMouseOver={onMouseOverRect}
353 onMouseOut={onMouseOutRect}
354 onClick={onClickRect}
355 data-raw-label={entry[0]}
356 >
357 <title>{barLabel}</title>
358 </rect>
359 {
360 <text
361 class={label_class}
362 x={labelX}
363 y="85"
364 visibility={showLabels ? "visible" : "hidden"}
365 transform={`rotate(90, ${labelX}, 85)`}
366 >
367 {label}
368 </text>
369 }
370 </>
371 );
372 })}
373 </g>
374
375 <text class="histo-label" x="2" y="97">
376 {histogramLabel}
377 </text>
378 <text class="hover-text" x="85" y="6">
379 {hoverLabel}
380 </text>
381
382 {/* The settings icon */}
383 <g
384 class="menu-icon"
385 transform="translate(2, 2) scale(0.3 0.3)"
386 onClick={toggleMenu}
387 >
388 <rect width="24" height="24" fill="white" stroke-widths="0.5"></rect>
389 <path
390 d="M3 5 H21 M3 12 H21 M3 19 H21"
391 stroke-width="1.75"
392 stroke-linecap="round"
393 />
394 <rect x="6" y="3" width="4" height="4" rx="1" stroke-width="1.5" />
395 <rect x="15" y="10" width="4" height="4" rx="1" stroke-width="1.5" />
396 <rect x="9" y="17" width="4" height="4" rx="1" stroke-width="1.5" />
397 </g>
398
399 {/* The info icon */}
400 <g
401 class="menu-icon"
402 transform="translate(156, 2) scale(0.3 0.3)"
403 onClick={toggleInfo}
404 >
405 <rect width="24" height="24" stroke-width="0"></rect>
406 <circle cx="12" cy="13" r="10" stroke-width="1.5" />
407 <path
408 stroke-width="2.5"
409 stroke-linecap="round"
410 d="M12 8 V8 M12 12.5 V18"
411 />
412 </g>
413
414 {/* The menu box */}
415 <g
416 id="menu"
417 ref={gMenu}
418 transform="translate(8, 2)"
419 style="display: none;"
420 >
421 <rect
422 x="0"
423 y="0"
424 rx="2"
425 width={menuBoxWidth}
426 height={menuBoxHeight}
427 class="menu-box"
428 ></rect>
429
430 {
431 // Menu items
432 menuItems.map((item, col) => {
433 return item.options.map((option, row) => {
434 let classList = "menu-item";
435 if (menuSelection[item.category] === row)
436 classList += " menu-selected";
437 return (
438 <>
439 <rect
440 x={2 + col * menuItemWidth}
441 y={2 + row * menuItemHeight}
442 rx="1"
443 class={classList}
444 onClick={() => menuClicked(item.category, row)}
445 ></rect>
446 <text
447 x={5 + col * menuItemWidth}
448 y={9 + row * menuItemHeight}
449 class="menu-text"
450 >
451 {option}
452 </text>
453 </>
454 );
455 });
456 })
457 }
458 {
459 // Column separators
460 menuItems.map((item, idx) => {
461 return idx >= menuItems.length - 1 ? null : (
462 <line
463 class="menu-separator"
464 x1={37 + idx * menuItemWidth}
465 y1="2"
466 x2={37 + idx * menuItemWidth}
467 y2={maxMenuOptions * menuItemHeight + 1}
468 ></line>
469 );
470 })
471 }
472 </g>
473
474 {/* The info box */}
475 <g ref={gInfo} style="display: none;">
476 <rect
477 width="155"
478 height="76"
479 rx="5"
480 x="5"
481 y="6"
482 class="help-info"
483 onClick={toggleInfo}
484 />
485 <text y="6" class="help-info-text">
486 <tspan x="10" dy="10">
487 This histogram shows the frequency of unique 'shot' results.
488 </tspan>
489 <tspan x="10" dy="10">
490 Click the top-left 'settings' icon for display options.
491 </tspan>
492 <tspan x="10" dy="10">
493 You can zoom the chart using the pinch-to-zoom gesture,
494 </tspan>
495 <tspan x="10" dy="10">
496 or use Ctrl+scroll wheel to zoom in/out.
497 </tspan>
498 <tspan x="10" dy="10">
499 To pan left &amp; right, press Shift while zooming.
500 </tspan>
501 <tspan x="10" dy="10">
502 Click on a bar to filter the shot details to that result.
503 </tspan>
504 <tspan x="10" dy="10">
505 Click anywhere in this box to dismiss it.
506 </tspan>
507 </text>
508 </g>
509 </svg>
510 </>
511 );
512}
513