Для решения этой задачи можно создать свой собственный элемент <element-map>, который показывает для некоторого элемента окошко, в котором указывается, в каком месте этот элемент виден, т. н. «навигатор».
Допустим, у нас есть элемент #app, внутри которого находится значительно бо́льшая по размеру картинка.
<section id="app">
<img>
</section>
Элемент <element-map> может располагаться внутри #app, в таком случае <element-map> использует #app, как просматриваемую область.
<section id="app">
<element-map></element-map>
<img>
</section>
Но вы можете захотеть, чтобы «навигатор» насполагался вне просматриваемой области. В таком случае можно добавить атрибут for, который в качестве значения принимает id просматриваемой области.
<section id="app">
<img>
</section>
<element-map for="app"></element-map>
Также вы можете захотеть менять размеры «навигатора», но напрямую это делать нельзя, поскольку пропорции «навигатора» зависят от просматриваемой области. Но можно указать максимальный размер «навигатора» при помощи атрибута max-size. (Например max-size="200×200", т. е. «навигатор» будет принимать размеры, ограниченные площадью 200 на 200 пикселей)
Вот реализация вышеописанного...
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
</head>
<body>
<script>
class ElementMap extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: absolute;
margin: 8px;
background-color: rgba(255, 255, 255, .5);
background-size: cover;
box-shadow: inset 0 0 1px black;
border-radius: 5px;
backdrop-filter: blur(5px);
overflow: hidden;
}
:host[for] {
position: relative;
}
.handle {
border-radius: 5px;
position: absolute;
cursor: grab;
box-shadow: inset 0 0 0 1px red, 0 0 0 100vmax rgba(0, 0, 0, 0.4);
}
</style>
<div class="handle"></div>
`;
this.applyScrollPosition = this.applyScrollPosition.bind(this);
this.pointerHandler = this.pointerHandler.bind(this);
this.handle = this.shadowRoot.querySelector(".handle");
this.handle.addEventListener("pointerdown", this.pointerHandler);
}
attributeChangedCallback(name, oldValue, newValue) {
if(name === "for" && newValue && this.ownerDocument) {
this.forElement = this.ownerDocument.getElementById(newValue);
}
}
static get observedAttributes() {
return ["max-size", "for"];
}
set forElement(element) {
this._container = element;
}
get forElement() {
if("_container" in this)
return this._container;
let element = null;
if(this.hasAttribute("for")) {
const id = this.getAttribute("for");
if(this.ownerDocument)
element = this.ownerDocument.getElementById(id);
} else
element = this.parentNode;
return this._container = element;
}
get maxSize() {
if(this.hasAttribute("max-size")) {
return this.getAttribute("max-size").trim().split(/\D/).map(Number);
} else {
return [150, 150];
}
}
connectedCallback() {
this.forElement.style.overflow = "auto";
this.applyScrollPosition();
this.forElement.addEventListener("scroll", this.applyScrollPosition);
this.ownerDocument.defaultView.addEventListener("load", this.applyScrollPosition);
}
disconnectedCallback() {
this.forElement.removeEventListener("scroll", this.applyScrollPosition);
this.forElement.ownerDocument.defaultView.removeEventListener("load", this.applyScrollPosition);
}
pointerHandler(event) {
const { screenX, screenY } = event;
if(event.type === "pointerdown") {
document.addEventListener("pointermove", this.pointerHandler);
document.addEventListener("pointerup", this.pointerHandler);
this._startX = screenX;
this._startY = screenY;
this._scrollLeft = this.forElement.scrollLeft;
this._scrollTop = this.forElement.scrollTop;
}
if(event.type === "pointerup") {
document.removeEventListener("pointermove", this.pointerHandler);
document.removeEventListener("pointerup", this.pointerHandler);
}
if(event.type === "pointermove") {
const dx = (screenX - this._startX) / this.ρ;
const dy = (screenY - this._startY) / this.ρ;
this.forElement.scrollTo(
this._scrollLeft + dx,
this._scrollTop + dy
);
}
event.preventDefault();
}
applyScrollPosition() {
const {
offsetWidth,
offsetHeight,
scrollWidth,
scrollHeight,
scrollLeft,
scrollTop
} = this.forElement;
this.ρ = Math.min(
this.maxSize[0] / scrollWidth,
this.maxSize[1] / scrollHeight
);
this.style.width = `${this.ρ * scrollWidth }px`;
this.style.height = `${this.ρ * scrollHeight}px`;
this.handle.style.cssText = `
width: ${this.ρ * offsetWidth }px;
height: ${this.ρ * offsetHeight}px;
left: ${this.ρ * scrollLeft }px;
top: ${this.ρ * scrollTop }px;
`;
}
}
customElements.define("element-map", ElementMap);
</script>
<style>::-webkit-scrollbar{height:0;width:0}</style>
<!-- Пример -->
<section id="app" style="max-width: 600px; height: 400px; box-shadow: 0 0 1em black;">
<element-map style="background-image: url(http://lowbird.com/data/images/2018/03/structureoffreemasonryac3.jpg);"></element-map>
<img src="http://lowbird.com/data/images/2018/03/structureoffreemasonryac3.jpg" alt="The Structure of Freemasonry" style="display: block;">
</section>
<element-map for="app" max-size="200×200"></element-map>
</body>
</html>