microsoft/qdk
Publicmirrored from https://github.com/microsoft/qdkAvailable
source/npm/qsharp/ux/histogram.tsx
506lines · 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 = /^\[(?:(Zero|One|Loss), *)*(Zero|One|Loss)\]$/; |
| 71 | function 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 | |
| 89 | export 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 & 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 | |