microsoft/qdk

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
billti/num2-sim

Branches

Tags

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

Clone

HTTPS

Download ZIP

source/npm/qsharp/ux/histogram.tsx

443lines · modecode

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