picture-annotation.py
Python script, ASCII text executable
1from pyscript import document, fetch as pyfetch 2from pyscript.ffi import create_proxy 3import asyncio 4import json 5 6document.getElementById("shape-options").style.display = "flex" 7 8image = document.getElementById("annotation-image") 9zone = document.getElementById("annotation-zone") 10confirm_button = document.getElementById("annotation-confirm") 11cancel_button = document.getElementById("annotation-cancel") 12backspace_button = document.getElementById("annotation-backspace") 13delete_button = document.getElementById("annotation-delete") 14previous_button = document.getElementById("annotation-previous") 15next_button = document.getElementById("annotation-next") 16save_button = document.getElementById("annotation-save") 17 18object_list = document.getElementById("object-types") 19object_list_content = document.getElementById("object-types-content") 20object_list_filter = document.getElementById("filter-object-types") 21 22confirm_button.style.display = "none" 23cancel_button.style.display = "none" 24backspace_button.style.display = "none" 25delete_button.style.display = "none" 26previous_button.style.display = "none" 27next_button.style.display = "none" 28shape_type = "" 29bbox_pos = None 30new_shape = None 31selected_shape = None 32 33 34def make_shape_container(): 35shape = document.createElementNS("http://www.w3.org/2000/svg", "svg") 36shape.setAttribute("width", "100%") 37shape.setAttribute("height", "100%") 38shape.setAttribute("viewBox", f"0 0 {image.naturalWidth} {image.naturalHeight}") 39shape.classList.add("shape-container") 40 41return shape 42 43 44async def get_all_objects(): 45response = await pyfetch("/api/object-types") 46if response.ok: 47return await response.json() 48 49 50def follow_cursor(event): 51rect = zone.getBoundingClientRect() 52x = (event.clientX - rect.left) / scale 53y = (event.clientY - rect.top) / scale 54vertical_ruler.style.left = str(x) + "px" 55horizontal_ruler.style.top = str(y) + "px" 56 57 58def change_object_type(event): 59global selected_shape 60if selected_shape is None: 61return 62selected_shape.setAttribute("data-object-type", event.currentTarget.value) 63 64 65change_object_type_proxy = create_proxy(change_object_type) 66 67 68def list_shapes(): 69shapes = list(zone.getElementsByClassName("shape")) 70json_shapes = [] 71for shape in shapes: 72shape_dict = {} 73if shape.tagName == "rect": 74shape_dict["type"] = "bbox" 75shape_dict["shape"] = { 76"x": float(shape.getAttribute("x")) / image.naturalWidth, 77"y": float(shape.getAttribute("y")) / image.naturalHeight, 78"w": float(shape.getAttribute("width")) / image.naturalWidth, 79"h": float(shape.getAttribute("height")) / image.naturalHeight 80} 81elif shape.tagName == "polygon" or shape.tagName == "polyline": 82if shape.tagName == "polygon": 83shape_dict["type"] = "polygon" 84elif shape.tagName == "polyline": 85shape_dict["type"] = "polyline" 86 87points = shape.getAttribute("points").split(" ") 88json_points = [] 89for point in points: 90x, y = point.split(",") 91x, y = float(x), float(y) 92json_points.append({ 93"x": x / image.naturalWidth, 94"y": y / image.naturalHeight 95}) 96 97shape_dict["shape"] = json_points 98elif shape.tagName == "circle" and shape.classList.contains("shape-point"): 99shape_dict["type"] = "point" 100shape_dict["shape"] = { 101"x": float(shape.getAttribute("cx")) / image.naturalWidth, 102"y": float(shape.getAttribute("cy")) / image.naturalHeight 103} 104else: 105continue 106 107shape_dict["object"] = shape.getAttribute("data-object-type") 108json_shapes.append(shape_dict) 109 110return json_shapes 111 112 113def put_shapes(json_shapes): 114for shape in json_shapes: 115new_shape = make_shape_container() 116zone_rect = zone.getBoundingClientRect() 117 118if shape["type"] == "bbox": 119rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 120rectangle.setAttribute("x", str(shape["shape"]["x"] * image.naturalWidth)) 121rectangle.setAttribute("y", str(shape["shape"]["y"] * image.naturalHeight)) 122rectangle.setAttribute("width", str(shape["shape"]["w"] * image.naturalWidth)) 123rectangle.setAttribute("height", str(shape["shape"]["h"] * image.naturalHeight)) 124rectangle.setAttribute("fill", "none") 125rectangle.setAttribute("data-object-type", shape["object"] or "") 126rectangle.classList.add("shape-bbox") 127rectangle.classList.add("shape") 128new_shape.appendChild(rectangle) 129elif shape["type"] == "polygon" or shape["type"] == "polyline": 130polygon = document.createElementNS("http://www.w3.org/2000/svg", shape["type"]) 131points = " ".join( 132[f"{point['x'] * image.naturalWidth},{point['y'] * image.naturalHeight}" for point in shape["shape"]]) 133polygon.setAttribute("points", points) 134polygon.setAttribute("fill", "none") 135polygon.setAttribute("data-object-type", shape["object"] or "") 136polygon.classList.add(f"shape-{shape['type']}") 137polygon.classList.add("shape") 138new_shape.appendChild(polygon) 139elif shape["type"] == "point": 140point = document.createElementNS("http://www.w3.org/2000/svg", "circle") 141point.setAttribute("cx", str(shape["shape"]["x"] * image.naturalWidth)) 142point.setAttribute("cy", str(shape["shape"]["y"] * image.naturalHeight)) 143point.setAttribute("r", "0") 144point.classList.add("shape-point") 145point.classList.add("shape") 146point.setAttribute("data-object-type", shape["object"] or "") 147new_shape.appendChild(point) 148 149zone.appendChild(new_shape) 150 151 152async def load_shapes(): 153resource_id = document.getElementById("resource-id").value 154response = await pyfetch(f"/picture/{resource_id}/get-annotations") 155if response.ok: 156shapes = await response.json() 157return shapes 158 159 160async def save_shapes(event): 161shapes = list_shapes() 162resource_id = document.getElementById("resource-id").value 163print("Saving shapes:", shapes) 164response = await pyfetch(f"/picture/{resource_id}/save-annotations", 165method="POST", 166headers={ 167"Content-Type": "application/json" 168}, 169body=json.dumps(shapes) 170) 171if response.ok: 172return await response 173 174 175save_shapes_proxy = create_proxy(save_shapes) 176save_button.addEventListener("click", save_shapes_proxy) 177delete_shape_key_proxy = create_proxy(lambda event: event.key == "Delete" and delete_shape_proxy(None)) 178next_shape_key_proxy = create_proxy(lambda event: event.key == "ArrowRight" and next_shape_proxy(None)) 179previous_shape_key_proxy = create_proxy(lambda event: event.key == "ArrowLeft" and previous_shape_proxy(None)) 180 181 182def get_centre(shape): 183if shape.tagName == "rect": 184x = float(shape.getAttribute("x")) + float(shape.getAttribute("width")) / 2 185y = float(shape.getAttribute("y")) + float(shape.getAttribute("height")) / 2 186elif shape.tagName == "polygon": 187points = shape.getAttribute("points").split(" ") 188# Average of the extreme points 189sorted_x = sorted([float(point.split(",")[0]) for point in points]) 190sorted_y = sorted([float(point.split(",")[1]) for point in points]) 191top = sorted_y[0] 192bottom = sorted_y[-1] 193left = sorted_x[0] 194right = sorted_x[-1] 195 196x = (left + right) / 2 197y = (top + bottom) / 2 198elif shape.tagName == "polyline": 199points = shape.getAttribute("points").split(" ") 200# Median point 201x = float(points[len(points) // 2].split(",")[0]) 202y = float(points[len(points) // 2].split(",")[1]) 203elif shape.tagName == "circle" and shape.classList.contains("shape-point"): 204x = float(shape.getAttribute("cx")) 205y = float(shape.getAttribute("cy")) 206else: 207return None 208 209return x, y 210 211 212async def update_object_list_filter(event): 213filter_text = event.currentTarget.value 214for label in object_list_content.children: 215if label.innerText.lower().find(filter_text.lower()) == -1: 216label.style.display = "none" 217else: 218label.style.display = "flex" 219 220 221async def focus_shape(shape): 222global selected_shape 223 224if shape_type != "select": 225return 226if selected_shape is not None: 227selected_shape.classList.remove("selected") 228 229selected_shape = shape 230 231selected_shape.classList.add("selected") 232 233objects = await get_all_objects() 234 235delete_button.style.display = "flex" 236next_button.style.display = "flex" 237previous_button.style.display = "flex" 238document.addEventListener("keydown", delete_shape_key_proxy) 239document.addEventListener("keydown", next_shape_key_proxy) 240document.addEventListener("keydown", previous_shape_key_proxy) 241 242object_list_content.innerHTML = "" 243object_list_filter.value = "" 244new_radio = document.createElement("input") 245new_radio.setAttribute("type", "radio") 246new_radio.setAttribute("name", "object-type") 247new_radio.setAttribute("tabindex", "1") # Focus first 248new_radio.setAttribute("value", "") 249new_label = document.createElement("label") 250new_label.appendChild(new_radio) 251new_label.append("Undefined") 252object_list_content.appendChild(new_label) 253new_radio.addEventListener("change", change_object_type_proxy) 254 255selected_object = selected_shape.getAttribute("data-object-type") 256if not selected_object: 257new_radio.setAttribute("checked", "") 258 259for object, description in objects.items(): 260new_radio = document.createElement("input") 261new_radio.setAttribute("type", "radio") 262new_radio.setAttribute("name", "object-type") 263new_radio.setAttribute("value", object) 264if selected_object == object: 265new_radio.setAttribute("checked", "") 266new_label = document.createElement("label") 267new_label.appendChild(new_radio) 268new_label.append(object) 269object_list_content.appendChild(new_label) 270new_radio.addEventListener("change", change_object_type_proxy) 271 272object_list.style.display = "flex" 273object_list_filter.focus() 274object_list_filter.addEventListener("input", update_object_list_filter) 275object_list.style.left = str(get_centre(shape)[0] / image.naturalWidth * 100) + "%" 276object_list.style.top = str(get_centre(shape)[1] / image.naturalHeight * 100) + "%" 277object_list.style.right = "auto" 278object_list.style.bottom = "auto" 279object_list.style.maxHeight = str(image.height - get_centre(shape)[1] / image.naturalHeight * image.height) + "px" 280object_list.style.maxWidth = str(image.width - get_centre(shape)[0] / image.naturalWidth * image.width) + "px" 281transform_origin = ["top", "left"] 282 283if get_centre(shape)[0] / image.naturalWidth * image.width + object_list.offsetWidth > image.width: 284object_list.style.left = "auto" 285object_list.style.right = str((image.naturalWidth - get_centre(shape)[0]) / image.naturalWidth * 100) + "%" 286object_list.style.maxWidth = str(get_centre(shape)[0] / image.naturalWidth * image.width) + "px" 287transform_origin[1] = "right" 288 289if get_centre(shape)[1] / image.naturalHeight * image.height + object_list.offsetHeight > image.height: 290object_list.style.top = "auto" 291object_list.style.bottom = str((image.naturalHeight - get_centre(shape)[1]) / image.naturalHeight * 100) + "%" 292object_list.style.maxHeight = str(get_centre(shape)[1] / image.naturalHeight * image.height) + "px" 293transform_origin[0] = "bottom" 294 295object_list.style.transformOrigin = " ".join(transform_origin) 296 297 298async def select_shape(event): 299await focus_shape(event.target) 300 301 302async def next_shape(event): 303global selected_shape 304if selected_shape is None: 305return 306 307selected_svg = selected_shape.parentNode 308 309while selected_svg is not None: 310next_sibling = selected_svg.nextElementSibling 311if next_sibling and next_sibling.classList.contains("shape-container"): 312selected_svg = next_sibling 313break 314elif next_sibling is None: 315# If no more siblings, loop back to the first child 316selected_svg = selected_svg.parentNode.firstElementChild 317while selected_svg is not None and not selected_svg.classList.contains( 318"shape-container"): 319selected_svg = selected_svg.nextElementSibling 320break 321else: 322selected_svg = next_sibling 323 324if selected_svg: 325shape = selected_svg.firstElementChild 326await focus_shape(shape) 327 328event.preventDefault() 329 330 331async def previous_shape(event): 332global selected_shape 333if selected_shape is None: 334return 335 336selected_svg = selected_shape.parentNode 337 338while selected_svg is not None: 339next_sibling = selected_svg.previousElementSibling 340if next_sibling and next_sibling.classList.contains("shape-container"): 341selected_svg = next_sibling 342break 343elif next_sibling is None: 344# If no more siblings, loop back to the last child 345selected_svg = selected_svg.parentNode.lastElementChild 346while selected_svg is not None and not selected_svg.classList.contains( 347"shape-container"): 348selected_svg = selected_svg.previousElementSibling 349break 350else: 351selected_svg = next_sibling 352 353if selected_svg: 354shape = selected_svg.firstElementChild 355await focus_shape(shape) 356 357event.preventDefault() 358 359 360def unselect_shape(event): 361global selected_shape 362 363if selected_shape is not None: 364selected_shape.classList.remove("selected") 365selected_shape = None 366 367object_list_content.innerHTML = "" 368object_list.style.display = "none" 369delete_button.style.display = "none" 370next_button.style.display = "none" 371previous_button.style.display = "none" 372document.removeEventListener("keydown", delete_shape_key_proxy) 373document.removeEventListener("keydown", next_shape_key_proxy) 374document.removeEventListener("keydown", previous_shape_key_proxy) 375 376 377def delete_shape(event): 378global selected_shape 379if selected_shape is None: 380return 381# Shape is SVG shape inside SVG so we need to remove the parent SVG 382selected_shape.parentNode.remove() 383selected_shape = None 384object_list_content.innerHTML = "" 385object_list.style.display = "none" 386delete_button.style.display = "none" 387next_button.style.display = "none" 388previous_button.style.display = "none" 389document.removeEventListener("keydown", delete_shape_key_proxy) 390document.removeEventListener("keydown", next_shape_key_proxy) 391document.removeEventListener("keydown", previous_shape_key_proxy) 392 393 394select_shape_proxy = create_proxy(select_shape) 395unselect_shape_proxy = create_proxy(unselect_shape) 396delete_shape_proxy = create_proxy(delete_shape) 397next_shape_proxy = create_proxy(next_shape) 398previous_shape_proxy = create_proxy(previous_shape) 399 400delete_button.addEventListener("click", delete_shape_proxy) 401next_button.addEventListener("click", next_shape_proxy) 402previous_button.addEventListener("click", previous_shape_proxy) 403 404# These are functions usable in JS 405cancel_bbox_proxy = create_proxy(lambda event: cancel_bbox(event)) 406make_bbox_proxy = create_proxy(lambda event: make_bbox(event)) 407make_polygon_proxy = create_proxy(lambda event: make_polygon(event)) 408follow_cursor_proxy = create_proxy(follow_cursor) 409 410 411def switch_shape(event): 412global shape_type 413object_list_content.innerHTML = "" 414unselect_shape(None) 415shape = event.currentTarget.id 416shape_type = shape 417for button in list(document.getElementById("shape-selector").children): 418if not button.classList.contains("button-flat"): 419button.classList.add("button-flat") 420event.currentTarget.classList.remove("button-flat") 421if shape_type == "select": 422# Add event listeners to existing shapes 423print(len(list(document.getElementsByClassName("shape"))), "shapes found") 424for shape in document.getElementsByClassName("shape"): 425print("Adding event listener to shape:", shape) 426shape.addEventListener("click", select_shape_proxy) 427image.addEventListener("click", unselect_shape_proxy) 428helper_message.innerText = "Click on a shape to select" 429# Cancel the current shape creation 430if shape_type == "shape-bbox": 431cancel_bbox(None) 432elif shape_type == "shape-polygon": 433cancel_polygon(None) 434elif shape_type == "shape-polyline": 435cancel_polygon(None) 436else: 437# Remove event listeners for selection 438for shape in document.getElementsByClassName("shape"): 439print("Removing event listener from shape:", shape) 440shape.removeEventListener("click", select_shape_proxy) 441image.removeEventListener("click", unselect_shape_proxy) 442helper_message.innerText = "Select a shape type then click on the image to begin defining it" 443print("Shape is now of type:", shape) 444 445 446def select_mode(): 447global shape_type 448document.getElementById("select").click() 449 450 451vertical_ruler = document.getElementById("annotation-ruler-vertical") 452horizontal_ruler = document.getElementById("annotation-ruler-horizontal") 453vertical_ruler_2 = document.getElementById("annotation-ruler-vertical-secondary") 454horizontal_ruler_2 = document.getElementById("annotation-ruler-horizontal-secondary") 455helper_message = document.getElementById("annotation-helper-message") 456 457helper_message.innerText = "Select a shape type then click on the image to begin defining it" 458 459 460def cancel_bbox(event): 461global bbox_pos, new_shape 462 463# Key must be ESCAPE 464if event is not None and hasattr(event, "key") and event.key != "Escape": 465return 466 467if new_shape is not None and event is not None: 468# Require event so the shape is kept when it ends normally 469new_shape.remove() 470zone.removeEventListener("click", make_bbox_proxy) 471document.removeEventListener("keydown", cancel_bbox_proxy) 472cancel_button.removeEventListener("click", cancel_bbox_proxy) 473 474bbox_pos = None 475vertical_ruler.style.display = "none" 476horizontal_ruler.style.display = "none" 477vertical_ruler_2.style.display = "none" 478horizontal_ruler_2.style.display = "none" 479zone.style.cursor = "auto" 480cancel_button.style.display = "none" 481helper_message.innerText = "Select a shape type then click on the image to begin defining it" 482new_shape = None 483update_transform() 484 485 486def make_bbox(event): 487global new_shape, bbox_pos 488zone_rect = zone.getBoundingClientRect() 489 490if bbox_pos is None: 491helper_message.innerText = "Now define the second point" 492 493bbox_pos = [(event.clientX - zone_rect.left) / zone_rect.width, 494(event.clientY - zone_rect.top) / zone_rect.height] 495vertical_ruler_2.style.left = str(bbox_pos[0] * 100) + "%" 496horizontal_ruler_2.style.top = str(bbox_pos[1] * 100) + "%" 497vertical_ruler_2.style.display = "block" 498horizontal_ruler_2.style.display = "block" 499 500else: 501x0, y0 = bbox_pos.copy() 502x1 = (event.clientX - zone_rect.left) / zone_rect.width 503y1 = (event.clientY - zone_rect.top) / zone_rect.height 504 505rectangle = document.createElementNS("http://www.w3.org/2000/svg", "rect") 506 507new_shape = make_shape_container() 508zone_rect = zone.getBoundingClientRect() 509 510new_shape.appendChild(rectangle) 511zone.appendChild(new_shape) 512 513minx = min(x0, x1) 514miny = min(y0, y1) 515maxx = max(x0, x1) 516maxy = max(y0, y1) 517 518rectangle.setAttribute("x", str(minx * image.naturalWidth)) 519rectangle.setAttribute("y", str(miny * image.naturalHeight)) 520rectangle.setAttribute("width", str((maxx - minx) * image.naturalWidth)) 521rectangle.setAttribute("height", str((maxy - miny) * image.naturalHeight)) 522rectangle.setAttribute("fill", "none") 523rectangle.setAttribute("data-object-type", "") 524rectangle.classList.add("shape-bbox") 525rectangle.classList.add("shape") 526 527# Add event listeners to the new shape 528rectangle.addEventListener("click", select_shape_proxy) 529 530cancel_bbox(None) 531 532 533polygon_points = [] 534 535 536def make_polygon(event): 537global new_shape, polygon_points 538 539polygon = new_shape.children[0] 540 541zone_rect = zone.getBoundingClientRect() 542 543polygon_points.append(((event.clientX - zone_rect.left) / zone_rect.width, 544(event.clientY - zone_rect.top) / zone_rect.height)) 545 546# Update the polygon 547polygon.setAttribute("points", " ".join( 548[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 549polygon_points])) 550 551 552def reset_polygon(): 553global new_shape, polygon_points 554 555zone.removeEventListener("click", make_polygon_proxy) 556document.removeEventListener("keydown", close_polygon_proxy) 557document.removeEventListener("keydown", cancel_polygon_proxy) 558document.removeEventListener("keydown", backspace_polygon_proxy) 559confirm_button.style.display = "none" 560cancel_button.style.display = "none" 561backspace_button.style.display = "none" 562confirm_button.removeEventListener("click", close_polygon_proxy) 563cancel_button.removeEventListener("click", cancel_polygon_proxy) 564backspace_button.removeEventListener("click", backspace_polygon_proxy) 565polygon_points.clear() 566 567zone.style.cursor = "auto" 568new_shape = None 569 570update_transform() 571 572 573def close_polygon(event): 574if event is not None and hasattr(event, "key") and event.key != "Enter": 575return 576# Polygon is already there, but we need to remove the events 577reset_polygon() 578 579 580def cancel_polygon(event): 581if event is not None and hasattr(event, "key") and event.key != "Escape": 582return 583# Delete the polygon 584new_shape.remove() 585reset_polygon() 586 587 588def backspace_polygon(event): 589if event is not None and hasattr(event, "key") and event.key != "Backspace": 590return 591if not polygon_points: 592return 593polygon_points.pop() 594polygon = new_shape.children[0] 595polygon.setAttribute("points", " ".join( 596[f"{point[0] * image.naturalWidth},{point[1] * image.naturalHeight}" for point in 597polygon_points])) 598 599 600close_polygon_proxy = create_proxy(close_polygon) 601cancel_polygon_proxy = create_proxy(cancel_polygon) 602backspace_polygon_proxy = create_proxy(backspace_polygon) 603 604 605def open_shape(event): 606global new_shape, bbox_pos 607if bbox_pos or shape_type == "select": 608return 609print("Creating a new shape of type:", shape_type) 610 611if shape_type == "shape-bbox": 612helper_message.innerText = ("Define the first point at the intersection of the lines " 613"by clicking on the image, or click the cross to cancel") 614 615cancel_button.addEventListener("click", cancel_bbox_proxy) 616document.addEventListener("keydown", cancel_bbox_proxy) 617cancel_button.style.display = "flex" 618bbox_pos = None 619zone.addEventListener("click", make_bbox_proxy) 620vertical_ruler.style.display = "block" 621horizontal_ruler.style.display = "block" 622zone.style.cursor = "crosshair" 623elif shape_type == "shape-polygon" or shape_type == "shape-polyline": 624if shape_type == "shape-polygon": 625helper_message.innerText = ("Click on the image to define the points of the polygon, " 626"press escape to cancel, enter to close, or backspace to " 627"remove the last point") 628elif shape_type == "shape-polyline": 629helper_message.innerText = ("Click on the image to define the points of the polyline, " 630"press escape to cancel, enter to finish, or backspace to " 631"remove the last point") 632 633if not polygon_points and not new_shape: 634new_shape = make_shape_container() 635zone_rect = zone.getBoundingClientRect() 636 637if not polygon_points and int(new_shape.children.length) == 0: 638zone.addEventListener("click", make_polygon_proxy) 639document.addEventListener("keydown", close_polygon_proxy) 640document.addEventListener("keydown", cancel_polygon_proxy) 641document.addEventListener("keydown", backspace_polygon_proxy) 642cancel_button.addEventListener("click", cancel_polygon_proxy) 643cancel_button.style.display = "flex" 644confirm_button.addEventListener("click", close_polygon_proxy) 645confirm_button.style.display = "flex" 646backspace_button.addEventListener("click", backspace_polygon_proxy) 647backspace_button.style.display = "flex" 648if shape_type == "shape-polygon": 649polygon = document.createElementNS("http://www.w3.org/2000/svg", "polygon") 650polygon.classList.add("shape-polygon") 651elif shape_type == "shape-polyline": 652polygon = document.createElementNS("http://www.w3.org/2000/svg", "polyline") 653polygon.classList.add("shape-polyline") 654polygon.setAttribute("fill", "none") 655polygon.setAttribute("data-object-type", "") 656polygon.classList.add("shape") 657new_shape.appendChild(polygon) 658zone.appendChild(new_shape) 659zone.style.cursor = "crosshair" 660elif shape_type == "shape-point": 661point = document.createElementNS("http://www.w3.org/2000/svg", "circle") 662zone_rect = zone.getBoundingClientRect() 663point.setAttribute("cx", str((event.clientX - zone_rect.left) / zone_rect.width * image.naturalWidth)) 664point.setAttribute("cy", str((event.clientY - zone_rect.top) / zone_rect.height * image.naturalHeight)) 665point.setAttribute("r", "0") 666point.classList.add("shape-point") 667point.classList.add("shape") 668point.setAttribute("data-object-type", "") 669 670new_shape = make_shape_container() 671zone_rect = zone.getBoundingClientRect() 672 673new_shape.appendChild(point) 674zone.appendChild(new_shape) 675 676new_shape = None 677 678update_transform() 679 680 681 682for button in list(document.getElementById("shape-selector").children): 683button.addEventListener("click", create_proxy(switch_shape)) 684print("Shape", button.id, "is available") 685 686zone.addEventListener("mousemove", follow_cursor_proxy) 687zone.addEventListener("click", create_proxy(open_shape)) 688 689# Zoom 690zoom_container = document.getElementById("annotation-zoom-container") 691scale = 1 692translate_x = 0 693translate_y = 0 694 695def update_transform(): 696zone.style.transform = f"scale({scale}) translate({translate_x}px, {translate_y}px)" 697object_list.style.transform = f"scale({1/scale})" # neutralise the zoom 698vertical_ruler.style.transform = f"scale({1/scale})" 699horizontal_ruler.style.transform = f"scale({1/scale})" 700vertical_ruler_2.style.transform = f"scale({1/scale})" 701horizontal_ruler_2.style.transform = f"scale({1/scale})" 702 703for svg in zone.getElementsByClassName("shape-container"): 704# Because vector-effect="non-scaling-stroke" doesn't work when an HTML ancestor is 705# scaled, we need to scale the shapes down then scale them back up 706svg.style.transform = f"scale({1/scale})" 707svg.style.transformOrigin = "0 0" 708for shape in svg.children: 709shape.setAttribute("transform", f"scale({scale})") 710shape.setAttribute("transform-origin", "0 0") 711 712scale_exponent = 0 713 714zoom_offset = (0, 0) 715 716def on_wheel(event): 717global scale, scale_exponent, translate_x, translate_y 718 719event.preventDefault() 720 721# Adjust scale exponent and compute new scale 722scale_exponent += (-1 if event.deltaY > 0 else 1) / 4 723new_scale = 2 ** scale_exponent 724# Update scale 725scale = new_scale 726 727update_transform() 728 729 730is_dragging = False 731start_x = 0 732start_y = 0 733 734def on_mouse_down(event): 735global is_dragging, start_x, start_y 736is_dragging = True 737start_x = event.clientX 738start_y = event.clientY 739 740def on_mouse_move(event): 741global translate_x, translate_y 742if is_dragging: 743translate_x += event.movementX / scale 744translate_y += event.movementY / scale 745update_transform() 746 747def on_mouse_up(event): 748global is_dragging 749is_dragging = False 750 751zoom_container.addEventListener("wheel", on_wheel) 752zoom_container.addEventListener("mousedown", on_mouse_down) 753document.addEventListener("mousemove", on_mouse_move) 754document.addEventListener("mouseup", on_mouse_up) 755 756# Load existing annotations, if any 757put_shapes(await load_shapes()) 758print("Ready!") 759