qsharp-lang 1.0.23-dev → 1.0.24-dev

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