microsoft/qdk
Publicmirrored from https://github.com/microsoft/qdkAvailable
source/npm/qsharp/ux/histogram.tsx
512lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | import { useEffect, useRef, useState } from "preact/hooks"; |
| 5 | |
| 6 | const 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 | ]; |
| 20 | const maxMenuOptions = 3; |
| 21 | |
| 22 | function 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 | |
| 70 | const reKetResult = |
| 71 | /^\[(?:(Zero|One|Loss|0|1|2|-), *)*(Zero|One|Loss|0|1|2|-)\]$/; |
| 72 | function 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 | |
| 95 | export 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 & 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 | |