microsoft/qdk
Publicmirrored from https://github.com/microsoft/qdkAvailable
source/npm/qsharp/ux/histogram.tsx
443lines · modecode
| 1 | // Copyright (c) Microsoft Corporation. |
| 2 | // Licensed under the MIT License. |
| 3 | |
| 4 | import { 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 | const defaultMenuSelection: { [idx: string]: number } = { |
| 22 | itemCount: 0, |
| 23 | sortOrder: 0, |
| 24 | labels: 0, |
| 25 | }; |
| 26 | |
| 27 | const reKetResult = /^\[(?:(Zero|One), *)*(Zero|One)\]$/; |
| 28 | function 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 | |
| 44 | export 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 & 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 | |