microsoft/qdk

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
v1.25.0

Branches

Tags

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

Clone

HTTPS

Download ZIP

source/npm/qsharp/ux/histogram.tsx

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