(function($) {
$.Viewer.prototype.makeScalebar = function(options) {
options = options || {};
options.viewer = this;
if (this.scalebar) {
this.scalebar.destroy();
}
this.scalebar = new $.Scalebar(options);
};
$.ScalebarType = {
NONE: 0,
MICROSCOPY: 1,
MAP: 2
};
$.ScalebarLocation = {
NONE: 0,
TOP_LEFT: 1,
TOP_RIGHT: 2,
BOTTOM_RIGHT: 3,
BOTTOM_LEFT: 4
};
$.Scalebar = function(options) {
options = options || {};
if (!options.viewer) {
throw new Error("A viewer must be specified.");
}
this.viewer = options.viewer;
this.ViewportSyncAPI = new ViewportSyncAPI(this.viewer);
this.setDrawScalebarFunction(options.type || $.ScalebarType.MICROSCOPY);
this.color = options.color || "black";
this.fontColor = options.fontColor || "black";
this.backgroundColor = options.backgroundColor || "none";
this.fontSize = options.fontSize || "";
this.fontFamily = options.fontFamily || "";
this.barThickness = options.barThickness || 3;
this.pixelsPerMeterX = options.pixelsPerMeterX;
this.pixelsPerMeterY = options.pixelsPerMeterY;
this.pixelsPerMeter = options.pixelsPerMeter || (options.pixelsPerMeterX + options.pixelsPerMeterY)/2;
this.location = options.location || $.ScalebarLocation.BOTTOM_LEFT;
this.xOffset = options.xOffset || 5;
this.yOffset = options.yOffset || 5;
this.stayInsideImage = isDefined(options.stayInsideImage) ?
options.stayInsideImage : true;
this.sizeAndTextRenderer = options.sizeAndTextRenderer ||
$.ScalebarSizeAndTextRenderer.METRIC_LENGTH;
this.magnificationContainerHeight = 210;
this.magnification = options.magnification || false;
this.refreshHandler = async function () {
if (!this.viewer.isOpen() ||
!this.drawScalebar ||
!this.pixelsPerMeter ||
!this.location) {
this.scalebarContainer.style.display = "none";
return;
}
this.scalebarContainer.style.display = "";
var props = this.sizeAndTextRenderer(this.currentResolution(), this.minWidth);
this.drawScalebar(props.size, props.text);
var location = this.getScalebarLocation();
this.scalebarContainer.style.left = (location.x + 5) + "px";
this.scalebarContainer.style.top = location.y + "px";
if (this.magnificationContainer) {
this.magnificationContainer.style.left = (location.x + 10) + "px";
const h = this.magnificationContainer.offsetHeight || this.magnificationContainerHeight || 0;
this.magnificationContainer.style.top = (location.y - h - 12) + "px";
}
}.bind(this);
this._init(options);
};
$.Scalebar.prototype = {
getReferencedTiledImage: function () {},
linkReferenceTileSourceIndex: function(index) {
this.getReferencedTiledImage = this.viewer.world.getItemAt.bind(this.viewer.world, index);
},
imagePixelSizeOnScreen: function() {
let viewport = this.viewer.viewport;
let zoom = viewport.getZoom(true);
if (this.__cachedZoom !== zoom) {
this.__cachedZoom = zoom;
let tiledImage = this.viewer.world.getItemAt(0);
if (tiledImage) {
this.__pixelRatio = tiledImageViewportToImageZoom(tiledImage, zoom);
} else {
this.__pixelRatio = 1;
}
}
return this.__pixelRatio;
},
currentResolution: function () {
return this.pixelsPerMeter * this.imagePixelSizeOnScreen()
},
imageLengthToGivenUnits: function(length) {
return getWithUnitRounded(length / this.pixelsPerMeter, this.lengthMetric());
},
imageAreaToGivenUnits: function(area) {
return getWithSquareUnitRounded(area / (this.pixelsPerMeter*this.pixelsPerMeter), this.areaMetric());
},
imageLength: function (length) {
return length / this.pixelsPerMeter;
},
imageArea: function (area) {
return area / (this.pixelsPerMeter*this.pixelsPerMeter);
},
lengthMetric: function () {
return this.sizeAndTextRenderer === $.ScalebarSizeAndTextRenderer.METRIC_LENGTH ? "m" : "px";
},
areaMetric: function () {
return this.sizeAndTextRenderer === $.ScalebarSizeAndTextRenderer.METRIC_LENGTH ? "m²" : "px²";
},
formatLength: function (unit) {return getWithUnitRounded(unit, this.lengthMetric())},
formatArea: function (unit) {return getWithSquareUnitRounded(unit, this.areaMetric())},
_init: function (options) {
if (!this._ui) {
this._ui = {
rotSliderEl: null,
magSliderEl: null,
onRotate: null,
onZoom: null,
collapsed: false,
collapsibles: [],
labelEl: null,
labelObjectUrl: null
};
this._originalClassTarget = noUiSlider.cssClasses.target;
}
if (!options.destroy) {
this.id = options.viewer.id + "-scale-bar";
this._active = true;
if (!this.scalebarContainer) {
this.scalebarContainer = document.createElement("div");
this.scalebarContainer.classList.add(
"absolute",
"z-[1]",
"m-0",
"pointer-events-none",
"select-none",
"glass",
"backdrop-blur-[2px]",
"px-3",
"py-1",
"ring-1",
"ring-base-300/40",
"text-xs",
"font-semibold"
);
this.scalebarContainer.id = this.id;
}
this.viewer.container.appendChild(this.scalebarContainer);
if (this.magnification > 0) {
const initSlider = () => {
if (!this._active) return;
const image = this.viewer.world.getItemAt(0);
if (!image) return;
if (this.magnificationContainer) return;
const viewport = this.viewer.viewport;
const inside = "oklch(var(--b1))";
const outside = "oklch(var(--er))";
this.magnificationContainer = document.createElement("div");
this.magnificationContainer.id = this.id + "-magnification";
this.magnificationContainer.classList.add(
"absolute",
"z-[1]",
"m-0",
"text-base-content",
"flex",
"flex-row",
"items-stretch",
"pointer-events-auto",
"select-none",
"bg-base-200",
"rounded-lg",
"pt-2"
);
this.magnificationContainer.style.height = `${this.magnificationContainerHeight}px`;
this.magnificationContainer.style.height = `${this.magnificationContainerHeight}px`;
this.magnificationContainer.style.width = "auto";
this._ui.collapsibles = [];
addSyncMenuChrome(this, this.viewer, this.ViewportSyncAPI, this.magnificationContainer);
const rotCol = document.createElement("div");
rotCol.className = "flex flex-col items-center pb-2 pl-1";
this._ui.collapsibles.push(rotCol);
const rotReadout = document.createElement("input");
rotReadout.type = "number";
rotReadout.min = "-180";
rotReadout.max = "180";
rotReadout.step = "1";
rotReadout.style.width = "50px";
rotReadout.className =
"input input-xs w-14 text-center text-xs font-bold px-1 rounded-lg bg-base-200 shadow text-base-content";
rotReadout.title = "Rotation in degrees — type a value and press Enter";
rotReadout.value = `${Math.round(toSignedRotation(viewport.getRotation()))}`;
const rotSliderContainer = document.createElement("div");
rotSliderContainer.className = "relative flex-1 w-1.5 my-2 self-end";
this._ui.rotSliderEl = rotSliderContainer;
rotCol.append(rotReadout, rotSliderContainer);
this.magnificationContainer.appendChild(rotCol);
noUiSlider.cssClasses.target += ' noUi-reverse';
noUiSlider.create(rotSliderContainer, {
start: toSignedRotation(viewport.getRotation()),
range: { min: -180, max: 180 },
direction: "rtl",
orientation: "vertical",
behaviour: "drag",
step: 1,
pips: {
mode: "values",
values: [-180, -90, 0, 90, 180],
density: 6,
format: { to: (v) => `${Math.round(v)}°` },
},
});
rotSliderContainer.querySelectorAll(".noUi-value-vertical").forEach((el) => {
el.classList.add(
"px-1.5",
"py-0.5",
"rounded-md",
"bg-base-200",
"text-base-content",
"shadow",
"font-semibold"
);
});
const rotSliderEl = rotSliderContainer.noUiSlider.target;
rotSliderEl.style.width = "6px";
rotSliderEl.style.border = "none";
rotSliderEl.style.background = inside;
let rotPrevent = false;
const setRotation = (deg) => {
const normalized = ((deg % 360) + 360) % 360;
this.viewer.viewport.setRotation(normalized);
rotReadout.value = `${Math.round(toSignedRotation(normalized))}`;
};
rotSliderContainer.noUiSlider.on("slide", (vals) => {
rotPrevent = true;
setRotation(parseFloat(vals[0]));
rotPrevent = false;
});
rotSliderContainer.noUiSlider.on("change", (vals) => {
rotPrevent = true;
setRotation(parseFloat(vals[0]));
rotPrevent = false;
});
const reflectRotation = () => {
if (rotPrevent) return;
const r = ((this.viewer.viewport.getRotation() % 360) + 360) % 360;
const s = toSignedRotation(r);
rotPrevent = true;
rotSliderContainer.noUiSlider.set(s);
rotReadout.value = `${Math.round(s)}`;
rotPrevent = false;
};
this.viewer.addHandler("rotate", reflectRotation);
this._ui.onRotate = reflectRotation;
const commitRotReadout = () => {
const v = parseFloat(rotReadout.value);
if (isNaN(v)) { reflectRotation(); return; }
const clamped = Math.max(-180, Math.min(180, v));
rotPrevent = true;
rotSliderContainer.noUiSlider.set(clamped);
rotPrevent = false;
setRotation(clamped);
};
rotReadout.addEventListener("change", commitRotReadout);
rotReadout.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRotReadout();
rotReadout.blur();
}
});
rotSliderContainer.querySelectorAll(".noUi-value").forEach((pip) => {
pip.classList.add("cursor-pointer", "hover:text-base-content");
pip.addEventListener("click", (e) => {
const t = (e.target.textContent || "").replace("°", "").trim();
const v = parseFloat(t);
if (!isNaN(v)) {
rotSliderContainer.noUiSlider.set(v);
setRotation(v);
}
});
});
const homeRot = rotSliderContainer.querySelectorAll(".noUi-value")
.item(2);
if (homeRot) {
homeRot.classList.remove("text-base-content/60");
homeRot.classList.add("text-base-content", "font-semibold");
homeRot.style.opacity = "1";
}
const minZoom = viewport.getMinZoom();
const maxZoom = viewport.getMaxZoom();
const nativeMag = this.magnification;
const getNativeVpZoom = () => {
const currentImage = this.viewer.world.getItemAt(0);
return currentImage ? currentImage.imageToViewportZoom(1) : 1;
};
const vpZoomToMag = (vpZ) => (vpZ / getNativeVpZoom()) * nativeMag;
const magToVpZoom = (mag) => (mag / nativeMag) * getNativeVpZoom();
const minMag = vpZoomToMag(minZoom);
const maxMag = vpZoomToMag(maxZoom);
const possibleSteps = [
0.01, 0.02, 0.05,
0.1, 0.2, 0.5,
1, 2, 5,
10, 20, 40,
80, 160, 240, 480
];
let pipValues = possibleSteps.filter(v => v >= minMag && v <= maxMag);
if (nativeMag >= minMag && nativeMag <= maxMag) {
const eps = nativeMag * 1e-6 + 1e-9;
const hasNative = pipValues.some(v => Math.abs(v - nativeMag) <= eps);
if (!hasNative) pipValues.push(nativeMag);
pipValues.sort((a,b) => a - b);
}
const toLog = (v) => Math.log2(v);
const toLin = (v) => Math.pow(2, v);
const range = {
'min': toLog(minMag),
'max': toLog(maxMag)
};
const totalRange = range.max - range.min;
const nativeVal = toLog(nativeMag);
let percentNative = ((nativeVal - range.min) / totalRange) * 100;
percentNative = Math.max(0, Math.min(100, percentNative));
const bgStyle = `linear-gradient(to top,
${inside} 0%,
${inside} ${percentNative}%,
${outside} ${percentNative}%,
${outside} 100%
)`;
const sliderContainer = document.createElement("span");
sliderContainer.className = "relative flex-1 w-1.5 my-2";
const sliderWrap = document.createElement("div");
sliderWrap.className = "relative flex-1 flex flex-col items-center justify-center w-full";
sliderWrap.style.minHeight = "120px";
this._ui.magSliderEl = sliderContainer;
const magCol = document.createElement("div");
magCol.className = "flex flex-col items-center pb-2 pr-4";
this._ui.collapsibles.push(magCol);
const magInput = document.createElement("input");
magInput.type = "number";
magInput.inputMode = "decimal";
magInput.step = "0.1";
magInput.className = "input input-xs";
magInput.min = String(minMag);
magInput.max = String(maxMag);
magInput.style.width = "45px";
magInput.style.transform = "translate(14px, 0)";
magInput.className =
"input-xs text-xs font-bold rounded-lg bg-base-200 shadow text-base-content";
magInput.style.padding = "0";
magInput.style.height = "24px";
magInput.style.fontSize = "11px";
sliderWrap.appendChild(magInput);
magCol.appendChild(sliderWrap);
sliderWrap.appendChild(sliderContainer);
this.magnificationContainer.appendChild(magCol);
this.viewer.container.appendChild(this.magnificationContainer);
noUiSlider.cssClasses.target = this._originalClassTarget;
noUiSlider.create(sliderContainer, {
range: range,
start: toLog(vpZoomToMag(viewport.getZoom())),
connect: false,
direction: "rtl",
orientation: "vertical",
behaviour: "drag",
tooltips: {
to: (v) => toLin(v).toFixed(1) + "x",
from: (s) => toLog(parseFloat(s))
},
pips: {
mode: 'values',
values: pipValues.map(toLog),
density: 5,
format: {
to: (v) => {
let val = toLin(v);
return (val < 1 ? val.toFixed(1) : Math.round(val)) + "x";
},
from: (s) => parseFloat(s)
}
}
});
const sliderEl = sliderContainer.noUiSlider.target;
sliderEl.style.width = "6px";
sliderEl.style.height = "100%";
sliderEl.style.border = "none";
sliderEl.style.background = bgStyle;
const setInputFromMag = (mag) => {
magInput.value = (mag < 1 ? mag.toFixed(1) : mag.toFixed(1));
};
setInputFromMag(vpZoomToMag(viewport.getZoom()));
const homeLog = nativeVal;
let bestEl = null;
let bestDist = Infinity;
sliderContainer.querySelectorAll(".noUi-value").forEach((el) => {
const v = parseFloat(el.getAttribute("data-value"));
if (!isFinite(v)) return;
const d = Math.abs(v - homeLog);
if (d < bestDist) {
bestDist = d;
bestEl = el;
}
el.classList.add(
"px-1.5",
"py-0.5",
"rounded-md",
"bg-base-200",
"text-base-content",
"shadow",
"font-semibold"
);
});
const onSliderChange = (values, handle) => {
const logVal = parseFloat(values[handle]);
const mag = toLin(logVal);
const targetZoom = magToVpZoom(mag);
this.viewer.viewport.zoomTo(targetZoom);
};
sliderContainer.noUiSlider.on("slide", onSliderChange);
sliderContainer.noUiSlider.on("change", onSliderChange);
const reflectUpdate = (e) => {
if (sliderContainer.noUiSlider._prevented) return;
const currentMag = vpZoomToMag(e.zoom);
sliderContainer.noUiSlider._prevented = true;
sliderContainer.noUiSlider.set(toLog(currentMag));
sliderContainer.noUiSlider._prevented = false;
setInputFromMag(currentMag);
};
this.viewer.addHandler('zoom', reflectUpdate);
this._ui.onZoom = reflectUpdate;
const stepSlider = (direction) => {
const currLog = parseFloat(sliderContainer.noUiSlider.get());
const pipLogs = pipValues.map(toLog).sort((a,b) => a-b);
let idx = -1;
let minDist = Infinity;
for(let i=0; i<pipLogs.length; i++) {
let d = Math.abs(pipLogs[i] - currLog);
if(d < minDist) { minDist = d; idx = i; }
}
let nextIdx = idx + direction;
if (nextIdx < 0) nextIdx = 0;
if (nextIdx >= pipLogs.length) nextIdx = pipLogs.length - 1;
const nextLog = pipLogs[nextIdx];
if (direction < 0 && currLog <= pipLogs[idx] + 0.01 && idx > 0) {
sliderContainer.noUiSlider.set(pipLogs[idx-1]);
this.viewer.viewport.zoomTo(magToVpZoom(toLin(pipLogs[idx-1])));
} else if (direction > 0 && currLog >= pipLogs[idx] - 0.01 && idx < pipLogs.length - 1) {
sliderContainer.noUiSlider.set(pipLogs[idx+1]);
this.viewer.viewport.zoomTo(magToVpZoom(toLin(pipLogs[idx+1])));
} else {
sliderContainer.noUiSlider.set(nextLog);
this.viewer.viewport.zoomTo(magToVpZoom(toLin(nextLog)));
}
};
const pips = sliderContainer.querySelectorAll(".noUi-value");
pips.forEach(p => {
p.classList.add("cursor-pointer", "hover:text-base-content");
p.addEventListener("click", (e) => {
let text = e.target.textContent || "";
let valText = text.replace('x','').trim();
if(!valText) return;
let val = parseFloat(valText);
if (!isNaN(val)) {
let logVal = toLog(val);
sliderContainer.noUiSlider.set(logVal);
this.viewer.viewport.zoomTo(magToVpZoom(val));
}
});
});
const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
const applyMagFromInput = () => {
const raw = parseFloat(magInput.value);
if (!isFinite(raw)) return;
const mag = clamp(raw, minMag, maxMag);
const logVal = toLog(mag);
sliderContainer.noUiSlider.set(logVal);
this.viewer.viewport.zoomTo(magToVpZoom(mag));
setInputFromMag(mag);
};
magInput.addEventListener("change", applyMagFromInput);
magInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
magInput.blur();
applyMagFromInput();
}
});
magInput.addEventListener("blur", applyMagFromInput);
this.refreshHandler();
};
if (this.viewer.isOpen()) initSlider();
else this.viewer.addOnceHandler('open', initSlider);
} else {
const initSlider = () => {
if (!this._active) return;
const image = this.viewer.world.getItemAt(0);
if (!image) return;
if (this.magnificationContainer) return;
const viewport = this.viewer.viewport;
const inside = "oklch(var(--b1))";
this.magnificationContainer = document.createElement("div");
this.magnificationContainer.id = this.id + "-magnification";
this.magnificationContainer.classList.add(
"absolute",
"z-[1]",
"m-0",
"text-base-content",
"flex",
"flex-row",
"items-stretch",
"pointer-events-auto",
"select-none",
"bg-base-200",
"rounded-lg",
"pt-2"
);
this.magnificationContainer.style.height = `${this.magnificationContainerHeight}px`;
this.magnificationContainer.style.width = "auto";
this._ui.collapsibles = [];
addSyncMenuChrome(this, this.viewer, this.ViewportSyncAPI, this.magnificationContainer);
const rotCol = document.createElement("div");
rotCol.className = "flex flex-col items-center pb-2 pl-1";
this._ui.collapsibles.push(rotCol);
const rotReadout = document.createElement("input");
rotReadout.type = "number";
rotReadout.min = "-180";
rotReadout.max = "180";
rotReadout.step = "1";
rotReadout.style.width = "50px";
rotReadout.className =
"input input-xs w-14 text-center text-xs font-bold px-1 rounded-lg bg-base-200 shadow text-base-content";
rotReadout.title = "Rotation in degrees — type a value and press Enter";
rotReadout.value = `${Math.round(toSignedRotation(viewport.getRotation()))}`;
const rotSliderContainer = document.createElement("div");
rotSliderContainer.className = "relative flex-1 w-1.5 my-2 self-end";
this._ui.rotSliderEl = rotSliderContainer;
rotCol.append(rotReadout, rotSliderContainer);
this.magnificationContainer.appendChild(rotCol);
noUiSlider.cssClasses.target += " noUi-reverse";
noUiSlider.create(rotSliderContainer, {
start: toSignedRotation(viewport.getRotation()),
range: { min: -180, max: 180 },
direction: "rtl",
orientation: "vertical",
behaviour: "drag",
step: 1,
pips: {
mode: "values",
values: [-180, -90, 0, 90, 180],
density: 6,
format: { to: (v) => `${Math.round(v)}°` },
},
});
rotSliderContainer.querySelectorAll(".noUi-value-vertical").forEach((el) => {
el.classList.add(
"px-1.5",
"py-0.5",
"rounded-md",
"bg-base-200",
"text-base-content",
"shadow",
"font-semibold"
);
});
const rotSliderEl = rotSliderContainer.noUiSlider.target;
rotSliderEl.style.width = "6px";
rotSliderEl.style.border = "none";
rotSliderEl.style.background = inside;
let rotPrevent = false;
const setRotation = (deg) => {
const normalized = ((deg % 360) + 360) % 360;
this.viewer.viewport.setRotation(normalized);
rotReadout.value = `${Math.round(toSignedRotation(normalized))}`;
};
rotSliderContainer.noUiSlider.on("slide", (vals) => {
rotPrevent = true;
setRotation(parseFloat(vals[0]));
rotPrevent = false;
});
rotSliderContainer.noUiSlider.on("change", (vals) => {
rotPrevent = true;
setRotation(parseFloat(vals[0]));
rotPrevent = false;
});
const reflectRotation = () => {
if (rotPrevent) return;
const r = ((this.viewer.viewport.getRotation() % 360) + 360) % 360;
const s = toSignedRotation(r);
rotPrevent = true;
rotSliderContainer.noUiSlider.set(s);
rotReadout.value = `${Math.round(s)}`;
rotPrevent = false;
};
this.viewer.addHandler("rotate", reflectRotation);
this._ui.onRotate = reflectRotation;
const commitRotReadout = () => {
const v = parseFloat(rotReadout.value);
if (isNaN(v)) { reflectRotation(); return; }
const clamped = Math.max(-180, Math.min(180, v));
rotPrevent = true;
rotSliderContainer.noUiSlider.set(clamped);
rotPrevent = false;
setRotation(clamped);
};
rotReadout.addEventListener("change", commitRotReadout);
rotReadout.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRotReadout();
rotReadout.blur();
}
});
rotSliderContainer.querySelectorAll(".noUi-value").forEach((pip) => {
pip.classList.add("cursor-pointer", "hover:text-base-content");
pip.addEventListener("click", (e) => {
const value = parseFloat((e.target.textContent || "").replace("°", "").trim());
if (!isNaN(value)) {
rotSliderContainer.noUiSlider.set(value);
setRotation(value);
}
});
});
const minLog = Math.log2(Math.max(viewport.getMinZoom(), Number.EPSILON));
const maxLog = Math.log2(Math.max(viewport.getMaxZoom(), viewport.getMinZoom() + Number.EPSILON));
let pipValues = [];
for (let step = Math.ceil(minLog); step <= Math.floor(maxLog); step++) {
pipValues.push(step);
}
if (pipValues.length < 2) {
const count = 5;
const span = maxLog - minLog;
pipValues = Array.from({length: count}, (_, i) => minLog + (span * i) / (count - 1));
}
const uniquePips = [];
pipValues.forEach((value) => {
if (!uniquePips.some((existing) => Math.abs(existing - value) < 1e-6)) {
uniquePips.push(value);
}
});
const sliderContainer = document.createElement("span");
sliderContainer.className = "relative flex-1 w-1.5 my-2";
const sliderWrap = document.createElement("div");
sliderWrap.className = "relative flex-1 flex flex-col items-center justify-center w-full";
sliderWrap.style.minHeight = "120px";
this._ui.magSliderEl = sliderContainer;
const magCol = document.createElement("div");
magCol.className = "flex flex-col items-center pb-2 pr-4";
this._ui.collapsibles.push(magCol);
const levelReadout = document.createElement("input");
levelReadout.type = "text";
levelReadout.readOnly = true;
levelReadout.className =
"input-xs text-xs font-bold rounded-lg bg-base-200 shadow text-base-content";
levelReadout.style.padding = "0";
levelReadout.style.height = "24px";
levelReadout.style.fontSize = "11px";
levelReadout.style.width = "56px";
levelReadout.style.transform = "translate(14px, 0)";
sliderWrap.appendChild(levelReadout);
magCol.appendChild(sliderWrap);
sliderWrap.appendChild(sliderContainer);
this.magnificationContainer.appendChild(magCol);
this.viewer.container.appendChild(this.magnificationContainer);
noUiSlider.cssClasses.target = this._originalClassTarget;
noUiSlider.create(sliderContainer, {
range: { min: minLog, max: maxLog },
start: Math.log2(Math.max(viewport.getZoom(), Number.EPSILON)),
connect: false,
direction: "rtl",
orientation: "vertical",
behaviour: "drag",
tooltips: false,
pips: {
mode: "values",
values: uniquePips,
density: 5,
format: {
to: (value) => {
const nearestIndex = uniquePips.reduce((best, current, index) => {
const distance = Math.abs(current - value);
return distance < best.distance ? {index, distance} : best;
}, {index: 0, distance: Infinity}).index;
return `L${nearestIndex + 1}`;
},
from: (value) => value
}
}
});
const sliderEl = sliderContainer.noUiSlider.target;
sliderEl.style.width = "6px";
sliderEl.style.height = "100%";
sliderEl.style.border = "none";
sliderEl.style.background = inside;
const formatLevel = (sliderValue) => {
const nearestIndex = uniquePips.reduce((best, current, index) => {
const distance = Math.abs(current - sliderValue);
return distance < best.distance ? {index, distance} : best;
}, {index: 0, distance: Infinity}).index;
return `L${nearestIndex + 1}`;
};
const setLevelReadout = (zoom) => {
levelReadout.value = formatLevel(Math.log2(Math.max(zoom, Number.EPSILON)));
};
setLevelReadout(viewport.getZoom());
sliderContainer.querySelectorAll(".noUi-value").forEach((el) => {
el.classList.add(
"px-1.5",
"py-0.5",
"rounded-md",
"bg-base-200",
"text-base-content",
"shadow",
"font-semibold",
"cursor-pointer",
"hover:text-base-content"
);
el.addEventListener("click", () => {
const label = el.textContent || "";
const match = /^L(\d+)$/i.exec(label.trim());
if (!match) return;
const index = Number.parseInt(match[1], 10) - 1;
const value = uniquePips[index];
if (!Number.isFinite(value)) return;
sliderContainer.noUiSlider.set(value);
this.viewer.viewport.zoomTo(Math.pow(2, value));
});
});
const onSliderChange = (values, handle) => {
const sliderValue = parseFloat(values[handle]);
this.viewer.viewport.zoomTo(Math.pow(2, sliderValue));
};
sliderContainer.noUiSlider.on("slide", onSliderChange);
sliderContainer.noUiSlider.on("change", onSliderChange);
const reflectUpdate = (e) => {
if (sliderContainer.noUiSlider._prevented) return;
sliderContainer.noUiSlider._prevented = true;
sliderContainer.noUiSlider.set(Math.log2(Math.max(e.zoom, Number.EPSILON)));
sliderContainer.noUiSlider._prevented = false;
setLevelReadout(e.zoom);
};
this.viewer.addHandler("zoom", reflectUpdate);
this._ui.onZoom = reflectUpdate;
this.refreshHandler();
};
if (this.viewer.isOpen()) initSlider();
else this.viewer.addOnceHandler('open', initSlider);
}
this.setMinWidth(options.minWidth || "150px");
this.viewer.addOnceHandler("update-viewport", this.prepareScalebar.bind(this));
this.viewer.addHandler("update-viewport", this.refreshHandler);
if (!this._viewerDestroyHandler) {
this._viewerDestroyHandler = () => {
this.destroy();
if (this.viewer.scalebar === this) {
this.viewer.scalebar = null;
}
};
}
if (!this._viewerDestroyHandlerBound) {
this.viewer.addHandler("destroy", this._viewerDestroyHandler);
this._viewerDestroyHandlerBound = true;
}
} else {
this._active = false;
this.viewer.removeHandler("update-viewport", this.refreshHandler);
if (this._ui.onRotate) {
this.viewer.removeHandler("rotate", this._ui.onRotate);
}
if (this._ui.onZoom) {
this.viewer.removeHandler("zoom", this._ui.onZoom);
}
if (this._ui.rotSliderEl?.noUiSlider) {
this._ui.rotSliderEl.noUiSlider.destroy();
}
if (this._ui.magSliderEl?.noUiSlider) {
this._ui.magSliderEl.noUiSlider.destroy();
}
if (this.scalebarContainer) {
this.scalebarContainer.remove();
}
if (this.magnificationContainer) {
this.magnificationContainer.remove();
}
this.magnificationContainer = null;
if (this._ui?.labelObjectUrl) {
try { URL.revokeObjectURL(this._ui.labelObjectUrl); } catch {}
}
this._ui = {
rotSliderEl: null,
magSliderEl: null,
onRotate: null,
onZoom: null,
collapsed: false,
collapsibles: [],
labelEl: null,
labelObjectUrl: null
};
this._applyCollapsed = null;
}
},
destroy: function() {
if (this._viewerDestroyHandlerBound && this._viewerDestroyHandler) {
this.viewer.removeHandler("destroy", this._viewerDestroyHandler);
this._viewerDestroyHandlerBound = false;
}
this._init({destroy: true});
},
setActive: function(active) {
if (this._active == active) return;
this._active = active;
if (active) {
if(this.magnificationContainer) this.magnificationContainer.style.visibility = "visible";
this.scalebarContainer.style.visibility = "visible";
this.viewer.addHandler("update-viewport", this.refreshHandler);
} else {
if(this.magnificationContainer) this.magnificationContainer.style.visibility = "hidden";
this.scalebarContainer.style.visibility = "hidden";
this.viewer.removeHandler("update-viewport", this.refreshHandler);
}
},
updateOptions: function(options) {
if (!options) {
return;
}
this._init(options);
if (isDefined(options.type)) {
this.setDrawScalebarFunction(options.type);
}
if (isDefined(options.minWidth)) {
this.setMinWidth(options.minWidth);
}
if (isDefined(options.color)) {
this.color = options.color;
}
if (isDefined(options.fontColor)) {
this.fontColor = options.fontColor;
}
if (isDefined(options.backgroundColor)) {
this.backgroundColor = options.backgroundColor;
}
if (isDefined(options.fontSize)) {
this.fontSize = options.fontSize;
}
if (isDefined(options.fontFamily)) {
this.fontFamily = options.fontFamily;
}
if (isDefined(options.barThickness)) {
this.barThickness = options.barThickness;
}
if ("pixelsPerMeter" in options) {
this.pixelsPerMeter = options.pixelsPerMeter;
}
if ("pixelsPerMeterX" in options) {
this.pixelsPerMeterX = options.pixelsPerMeterX;
}
if ("pixelsPerMeterY" in options) {
this.pixelsPerMeterY = options.pixelsPerMeterY;
}
if (!isDefined(this.pixelsPerMeter) && isDefined(this.pixelsPerMeterX) && isDefined(this.pixelsPerMeterY)) {
this.pixelsPerMeter = (this.pixelsPerMeterX + this.pixelsPerMeterY) / 2;
}
if (isDefined(options.location)) {
this.location = options.location;
}
if (isDefined(options.xOffset)) {
this.xOffset = options.xOffset;
}
if (isDefined(options.yOffset)) {
this.yOffset = options.yOffset;
}
if (isDefined(options.stayInsideImage)) {
this.stayInsideImage = options.stayInsideImage;
}
if (isDefined(options.sizeAndTextRenderer)) {
this.sizeAndTextRenderer = options.sizeAndTextRenderer;
}
if (isDefined(options.magnification)) {
this.magnification = options.magnification;
}
},
setDrawScalebarFunction: function(type) {
if (!type) {
this.drawScalebar = null;
}
else if (type === $.ScalebarType.MAP) {
this.drawScalebar = this.drawMapScalebar;
this.prepareScalebar = this.prepareMapScalebar;
this.prepareMapScalebar();
} else {
this.drawScalebar = this.drawMicroscopyScalebar;
this.prepareScalebar = this.prepareMicroscopyScalebar;
}
},
setMinWidth: function(minWidth) {
this.scalebarContainer.style.width = minWidth;
this.scalebarContainer.style.display = "";
this.minWidth = this.scalebarContainer.offsetWidth;
},
refresh: function(options) {
if (this.scalebarContainer) {
this._init({destroy: true});
}
this.updateOptions(options);
this.prepareScalebar();
this.refreshHandler();
},
_prepareScalebarCommon: function () {
this.scalebarContainer.style.fontSize = this.fontSize;
this.scalebarContainer.style.fontFamily = this.fontFamily;
this.scalebarContainer.style.textAlign = "center";
this.scalebarContainer.style.fontWeight = "600";
this.scalebarContainer.style.color = this.fontColor;
this.scalebarContainer.style.backgroundColor = this.backgroundColor;
},
prepareMicroscopyScalebar: function () {
this._prepareScalebarCommon();
this.scalebarContainer.style.border = "none";
},
prepareMapScalebar: function () {
this._prepareScalebarCommon();
this.scalebarContainer.style.borderTop = "none";
},
drawMicroscopyScalebar: function(size, text) {
this.scalebarContainer.style.borderBottom =
this.barThickness + "px solid " + this.color;
this.scalebarContainer.style.borderLeft =
this.barThickness + "px solid " + this.color;
this.scalebarContainer.style.borderRight =
this.barThickness + "px solid " + this.color;
this.scalebarContainer.innerHTML = text;
this.scalebarContainer.style.width = size + "px";
},
drawMapScalebar: function(size, text) {
this.scalebarContainer.style.textAlign = "center";
this.scalebarContainer.style.border = this.barThickness + "px solid " + this.color;
this.scalebarContainer.innerHTML = text;
this.scalebarContainer.style.width = size + "px";
},
getScalebarLocation: function() {
var barWidth = this.scalebarContainer.offsetWidth;
var barHeight = this.scalebarContainer.offsetHeight;
var container = this.viewer.container;
var x = 0;
var y = 0;
var pixel;
if (this.location === $.ScalebarLocation.TOP_LEFT) {
if (this.stayInsideImage) {
pixel = this.viewer.viewport.pixelFromPoint(
new $.Point(0, 0), true);
if (!this.viewer.wrapHorizontal) {
x = Math.max(pixel.x, 0);
}
if (!this.viewer.wrapVertical) {
y = Math.max(pixel.y, 0);
}
}
return new $.Point(x + this.xOffset, y + this.yOffset);
} else if (this.location === $.ScalebarLocation.TOP_RIGHT) {
x = container.offsetWidth - barWidth;
if (this.stayInsideImage) {
pixel = this.viewer.viewport.pixelFromPoint(
new $.Point(1, 0), true);
if (!this.viewer.wrapHorizontal) {
x = Math.min(x, pixel.x - barWidth);
}
if (!this.viewer.wrapVertical) {
y = Math.max(y, pixel.y);
}
}
return new $.Point(x - this.xOffset, y + this.yOffset);
} else if (this.location === $.ScalebarLocation.BOTTOM_RIGHT) {
x = container.offsetWidth - barWidth;
y = container.offsetHeight - barHeight;
if (this.stayInsideImage) {
pixel = this.viewer.viewport.pixelFromPoint(
new $.Point(1, 1 / this.viewer.source.aspectRatio),
true);
if (!this.viewer.wrapHorizontal) {
x = Math.min(x, pixel.x - barWidth);
}
if (!this.viewer.wrapVertical) {
y = Math.min(y, pixel.y - barHeight);
}
}
return new $.Point(x - this.xOffset, y - this.yOffset);
} else if (this.location === $.ScalebarLocation.BOTTOM_LEFT) {
y = container.offsetHeight - barHeight;
if (this.stayInsideImage) {
pixel = this.viewer.viewport.pixelFromPoint(
new $.Point(0, 1 / this.viewer.source.aspectRatio),
true);
if (!this.viewer.wrapHorizontal) {
x = Math.max(x, pixel.x);
}
if (!this.viewer.wrapVertical) {
y = Math.min(y, pixel.y - barHeight);
}
}
return new $.Point(x + this.xOffset, y - this.yOffset);
}
},
getAsCanvas: function() {
var canvas = document.createElement("canvas");
canvas.width = this.scalebarContainer.offsetWidth;
canvas.height = this.scalebarContainer.offsetHeight;
var context = canvas.getContext("2d");
context.fillStyle = this.backgroundColor;
context.fillRect(0, 0, canvas.width, canvas.height);
context.fillStyle = this.color;
context.fillRect(0, canvas.height - this.barThickness,
canvas.width, canvas.height);
if (this.drawScalebar === this.drawMapScalebar) {
context.fillRect(0, 0, this.barThickness, canvas.height);
context.fillRect(canvas.width - this.barThickness, 0,
this.barThickness, canvas.height);
}
context.font = window.getComputedStyle(this.scalebarContainer).font;
context.textAlign = "center";
context.textBaseline = "middle";
context.fillStyle = this.fontColor;
var hCenter = canvas.width / 2;
var vCenter = canvas.height / 2;
context.fillText(this.scalebarContainer.textContent, hCenter, vCenter);
return canvas;
},
getImageWithScalebarAsCanvas: function() {
var imgCanvas = this.viewer.drawer.canvas;
var newCanvas = document.createElement("canvas");
newCanvas.width = imgCanvas.width;
newCanvas.height = imgCanvas.height;
var newCtx = newCanvas.getContext("2d");
newCtx.drawImage(imgCanvas, 0, 0);
var scalebarCanvas = this.getAsCanvas();
var location = this.getScalebarLocation();
newCtx.drawImage(scalebarCanvas, location.x, location.y);
return newCanvas;
},
};
$.ScalebarSizeAndTextRenderer = {
METRIC_LENGTH: function(ppm, minSize) {
return getScalebarSizeAndTextForMetric("m", ppm, minSize);
},
IMPERIAL_LENGTH: function(ppm, minSize) {
var maxSize = minSize * 2;
var ppi = ppm * 0.0254;
if (maxSize < ppi * 12) {
if (maxSize < ppi) {
var ppt = ppi / 1000;
return getScalebarSizeAndText("th", ppt, minSize);
}
return getScalebarSizeAndText("in", ppi, minSize);
}
var ppf = ppi * 12;
if (maxSize < ppf * 2000) {
return getScalebarSizeAndText("ft", ppf, minSize);
}
var ppmi = ppf * 5280;
return getScalebarSizeAndText("mi", ppmi, minSize);
},
ASTRONOMY: function(ppa, minSize) {
var maxSize = minSize * 2;
if (maxSize < ppa * 60) {
return getScalebarSizeAndText("\"", ppa, minSize, false, '');
}
var ppminutes = ppa * 60;
if (maxSize < ppminutes * 60) {
return getScalebarSizeAndText("\'", ppminutes, minSize, false, '');
}
var ppd = ppminutes * 60;
return getScalebarSizeAndText("°", ppd, minSize, false, '');
},
STANDARD_TIME: function(pps, minSize) {
var maxSize = minSize * 2;
if (maxSize < pps * 60) {
return getScalebarSizeAndTextForMetric("s", pps, minSize);
}
var ppminutes = pps * 60;
if (maxSize < ppminutes * 60) {
return getScalebarSizeAndText("minute", ppminutes, minSize, true);
}
var pph = ppminutes * 60;
if (maxSize < pph * 24) {
return getScalebarSizeAndText("hour", pph, minSize, true);
}
var ppd = pph * 24;
if (maxSize < ppd * 365.25) {
return getScalebarSizeAndText("day", ppd, minSize, true);
}
var ppy = ppd * 365.25;
return getScalebarSizeAndText("year", ppy, minSize, true);
},
METRIC_GENERIC: getScalebarSizeAndTextForMetric
};
function tiledImageViewportToImageZoom(tiledImage, viewportZoom) {
var ratio = tiledImage._scaleSpring.current.value *
tiledImage.viewport._containerInnerSize.x /
tiledImage.source.dimensions.x;
return ratio * viewportZoom;
}
function getScalebarSizeAndText(unitSuffix, ppm, minSize, handlePlural, spacer) {
spacer = spacer === undefined ? ' ' : spacer;
var value = normalize(ppm, minSize);
var factor = roundSignificand(value / ppm * minSize, 3);
var size = value * minSize;
var plural = handlePlural && factor > 1 ? "s" : "";
return {
size: size,
text: factor + spacer + unitSuffix + plural
};
}
function getScalebarSizeAndTextForMetric(unitSuffix, ppm, minSize, shouldFactorizeUnit=true) {
var value = normalize(ppm, minSize);
var factor = roundSignificand(value / ppm * minSize, 3);
var size = value * minSize;
var valueWithUnit = shouldFactorizeUnit ? getWithUnit(factor, unitSuffix) : getWithSpaces(factor, unitSuffix);
return {
size: size,
text: valueWithUnit
};
}
function normalize(value, minSize) {
var significand = getSignificand(value);
var minSizeSign = getSignificand(minSize);
var result = getSignificand(significand / minSizeSign);
if (result >= 5) {
result /= 5;
}
if (result >= 4) {
result /= 4;
}
if (result >= 2) {
result /= 2;
}
return result;
}
function getSignificand(x) {
return x * Math.pow(10, Math.ceil(-log10(x)));
}
function roundSignificand(x, decimalPlaces) {
var exponent = -Math.ceil(-log10(x));
var power = decimalPlaces - exponent;
var significand = x * Math.pow(10, power);
if (power < 0) {
return Math.round(significand) * Math.pow(10, -power);
}
return Math.round(significand) / Math.pow(10, power);
}
function log10(x) {
return Math.log(x) / Math.log(10);
}
function getWithUnit(value, unitSuffix) {
const negative = value < 0;
value = Math.abs(value);
if (value < 0.000001) {
return (negative ? "-" : "") + value * 1000000000 + " n" + unitSuffix;
}
if (value < 0.001) {
return (negative ? "-" : "") + value * 1000000 + " μ" + unitSuffix;
}
if (value < 1) {
return (negative ? "-" : "") + value * 1000 + " m" + unitSuffix;
}
if (value < 1000) {
return (negative ? "-" : "") + value + unitSuffix;
}
if (value >= 1000) {
return (negative ? "-" : "") + value / 1000 + " k" + unitSuffix;
}
return (negative ? "-" : "") + getWithSpaces(value / 1000, "k" + unitSuffix);
}
function getWithUnitRounded(value, unitSuffix) {
const negative = value < 0;
value = Math.abs(value);
if (value < 0.000001) {
return (negative ? "-" : "") + (Math.round(value * 100000000000) / 100) + " n" + unitSuffix;
}
if (value < 0.001) {
return (negative ? "-" : "") + (Math.round(value * 100000000) / 100) + " μ" + unitSuffix;
}
if (value < 1) {
return (negative ? "-" : "") + (Math.round(value * 100000) / 100) + " m" + unitSuffix;
}
if (value < 1000) {
return (negative ? "-" : "") + (Math.round(value * 100) / 100) + unitSuffix;
}
if (value >= 1000) {
return (negative ? "-" : "") + (Math.round(value / 10) / 100) + " k" + unitSuffix;
}
return (negative ? "-" : "") + getWithSpaces(Math.round(value) / 1000, "k" + unitSuffix);
}
function getWithSquareUnitRounded(value, unitSuffix) {
const negative = value < 0;
value = Math.abs(value);
if (value < 0.000001) {
return (negative ? "-" : "") + getWithSpaces(Math.round(value * 100000000000000) / 100, " μ" + unitSuffix);
}
if (value < 1) {
return (negative ? "-" : "") + getWithSpaces(Math.round(value * 100000000) / 100, " m" + unitSuffix);
}
if (value < 1000000) {
return (negative ? "-" : "") + getWithSpaces(Math.round(value * 100) / 100, unitSuffix);
}
if (value >= 1000000) {
return (negative ? "-" : "") + getWithSpaces(Math.round(value / 10) / 100, " k" + unitSuffix);
}
return (negative ? "-" : "") + getWithSpaces(Math.round(value) / 1000, "k" + unitSuffix);
}
function getWithSpaces(value, unitSuffix) {
if (value < 0) return "Negative!";
return value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, " ") + " " + unitSuffix;
}
function isDefined(variable) {
return typeof (variable) !== "undefined";
}
function toSignedRotation(deg) {
const d = ((deg % 360) + 360) % 360;
return d > 180 ? d - 360 : d;
}
function SyncToggleButton(viewer, tool) {
const enabled = van.state(!!tool?.isEnabled?.());
const busy = van.state(false);
const progressText = van.state("");
const isRef = van.state(false);
const updateFromTool = () => {
enabled.val = !!tool?.isEnabled?.();
const S = tool?.constructor?._session;
isRef.val = !!enabled.val && !!S?.leaderId && viewer.uniqueId === S.leaderId;
};
const setProgress = (txt) => { progressText.val = txt || ""; };
const setBusy = (b) => { busy.val = !!b; };
tool.__ui = { setProgress, setBusy };
const onClick = async () => {
if (!tool) return;
if (busy.val) {
tool.cancelCalibration?.();
return;
}
setBusy(true);
if (VIEWER_MANAGER.viewers.length < 2) {
Dialogs?.show?.("Sync is possible with more than one slide opened.");
setBusy(false);
return;
}
try {
if (enabled.val) {
tool.disable();
enabled.val = false;
setProgress("");
Dialogs?.show?.("Sync disabled", 1200, Dialogs.MSG_INFO);
} else {
setProgress("0/3");
await tool.enable();
enabled.val = true;
setProgress("");
Dialogs?.show?.("Sync enabled", 1200, Dialogs.MSG_SUCCESS);
}
} catch (e) {
tool.disable?.();
enabled.val = false;
setProgress("");
if (e && /cancel/i.test(e.message || "")) {
Dialogs?.show?.("Sync cancelled", 1200, Dialogs.MSG_INFO);
} else {
console.error(e);
Dialogs?.show?.("Sync not enabled", 1600, Dialogs.MSG_WARN);
}
} finally {
setBusy(false);
}
};
viewer.__syncToolChanged = updateFromTool;
return van.tags.button(
{
class: () => [
"btn btn-xs border-none px-1",
enabled.val ? (isRef.val ? "btn-primary" : "btn-success") : ""
].join(" "),
onclick: onClick,
title: () => (busy.val ? "Cancel calibration" : (enabled.val ? "Disable sync" : "Enable sync"))
},
van.tags.span({ class: "font-bold", style: "font-size:10px;line-height:1" },
() => {
if (busy.val) return "...";
if (!enabled.val) return "LINK";
return isRef.val ? "REF" : "SYNC";
}
)
);
}
function renderImageLikeInto(container, src) {
let objectUrl = null;
let node = null;
if (typeof src === "string") {
node = document.createElement("img");
node.src = src;
} else if (src instanceof Blob) {
objectUrl = URL.createObjectURL(src);
node = document.createElement("img");
node.src = objectUrl;
} else if (typeof HTMLImageElement !== "undefined" && src instanceof HTMLImageElement) {
node = src.cloneNode(true);
} else if (typeof HTMLCanvasElement !== "undefined" && src instanceof HTMLCanvasElement) {
node = src;
} else if (src && src.canvas instanceof HTMLCanvasElement) {
node = src.canvas;
} else {
return null;
}
if (node.tagName === "IMG") {
node.alt = "Slide label";
node.loading = "lazy";
}
node.style.maxWidth = "100%";
node.style.maxHeight = "100%";
node.style.display = "block";
container.innerHTML = "";
container.appendChild(node);
return { node, objectUrl };
}
function addSyncMenuChrome(scalebar, viewer, tool, magnificationContainer) {
scalebar._ui.collapsibles = scalebar._ui.collapsibles || [];
const header = document.createElement("div");
header.className = "absolute flex flex-row items-center justify-between gap-2";
header.style.left = "-10px";
header.style.top = "-15px";
header.style.right = "-10px";
header.style.zIndex = "3";
magnificationContainer.appendChild(header);
scalebar._ui.header = header;
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "btn btn-xs border-none px-0.5";
toggle.title = "Minimize";
toggle.innerHTML = '<span class="font-bold" style="font-size:10px;line-height:1">▾</span>';
header.appendChild(toggle);
const sync = SyncToggleButton(viewer, tool);
header.appendChild(sync);
const reset = document.createElement("button");
reset.type = "button";
reset.className = "btn btn-xs text-error border-none px-0.5";
reset.title = "Reset this viewer's alignment (Shift+click: clear whole sync session)";
reset.innerHTML = '<span class="font-bold leading-none" style="font-size:10px">✕</span>';
reset.style.display = "none";
const updateResetVisibility = () => {
const S = tool?.constructor?._session;
const shouldShow = !!(S && S.leaderId) && !scalebar._ui.collapsed;
reset.style.display = shouldShow ? "" : "none";
};
reset.addEventListener("click", async (e) => {
if (!tool) return;
try {
if (e.shiftKey) {
tool.resetSession();
Dialogs?.show?.("Sync session cleared", 1400, Dialogs.MSG_INFO);
} else {
tool.resetViewer();
Dialogs?.show?.("Sync cleared", 1200, Dialogs.MSG_INFO);
}
} catch (err) {
console.error(err);
Dialogs?.show?.("Reset failed", 1400, Dialogs.MSG_WARN);
} finally {
updateResetVisibility();
}
});
header.appendChild(reset);
const prev = viewer.__syncToolChanged;
viewer.__syncToolChanged = () => {
prev?.();
updateResetVisibility();
};
updateResetVisibility();
const LABEL_BOX = { width: "56px", height: "26px" };
const LABEL_SCALE_HOVER = 4.5;
const labelEl = document.createElement("div");
labelEl.className = "rounded-md bg-base-200 overflow-hidden flex items-center justify-center cursor-zoom-in";
labelEl.style.width = LABEL_BOX.width;
labelEl.style.height = LABEL_BOX.height;
labelEl.style.flex = "0 0 auto";
labelEl.style.transformOrigin = "left center";
labelEl.style.transition = "transform 0.18s ease";
labelEl.style.display = "none";
labelEl.title = "Slide label (hover to enlarge)";
header.appendChild(labelEl);
scalebar._ui.labelEl = labelEl;
labelEl.addEventListener("mouseenter", () => {
if (scalebar._ui.collapsed) return;
if (labelEl.style.pointerEvents === "none") return;
labelEl.style.transform = `scale(${LABEL_SCALE_HOVER})`;
labelEl.style.position = "relative";
labelEl.style.zIndex = "40";
});
labelEl.addEventListener("mouseleave", () => {
labelEl.style.transform = "";
labelEl.style.position = "";
labelEl.style.zIndex = "";
});
const showLabelPlaceholder = () => {
if (scalebar._ui?.labelEl !== labelEl) return;
labelEl.innerHTML = "";
labelEl.classList.remove("cursor-zoom-in");
labelEl.classList.add(
"border", "border-dashed", "border-base-content/30"
);
labelEl.style.cursor = "default";
labelEl.style.pointerEvents = "none";
const span = document.createElement("span");
span.className = "italic text-base-content/60 whitespace-nowrap px-1";
span.style.fontSize = "9px";
span.style.lineHeight = "1";
span.textContent = "no label";
labelEl.appendChild(span);
labelEl.title = "No slide label available";
if (!scalebar._ui.collapsed) labelEl.style.display = "";
scalebar.refreshHandler?.();
};
const tile = scalebar.getReferencedTiledImage?.() || viewer.world.getItemAt(0);
const getLabel = tile?.source?.getLabel;
if (typeof getLabel === "function") {
Promise.resolve(getLabel.call(tile.source)).then((res) => {
if (scalebar._ui?.labelEl !== labelEl) return;
if (!res) { showLabelPlaceholder(); return; }
const rendered = renderImageLikeInto(labelEl, res);
if (!rendered) { showLabelPlaceholder(); return; }
if (rendered.node && rendered.node.tagName === "IMG") {
rendered.node.addEventListener("error", () => {
if (rendered.objectUrl) {
try { URL.revokeObjectURL(rendered.objectUrl); } catch {}
scalebar._ui.labelObjectUrl = null;
}
showLabelPlaceholder();
});
}
scalebar._ui.labelObjectUrl = rendered.objectUrl || null;
if (!scalebar._ui.collapsed) labelEl.style.display = "";
scalebar.refreshHandler?.();
}).catch(() => { showLabelPlaceholder(); });
} else {
showLabelPlaceholder();
}
scalebar._ui.collapsed = !!scalebar._ui.collapsed;
scalebar._applyCollapsed = () => {
const c = !!scalebar._ui.collapsed;
for (const el of scalebar._ui.collapsibles) {
if (el) el.style.display = c ? "none" : "";
}
labelEl.style.transform = "";
labelEl.style.position = "";
labelEl.style.zIndex = "";
labelEl.style.display = c ? "none" : "";
updateResetVisibility();
if (c) {
header.style.position = "relative";
header.style.left = "";
header.style.top = "";
header.style.right = "";
magnificationContainer.classList.remove("pt-2", "bg-base-200", "rounded-lg", "items-stretch");
magnificationContainer.classList.add("items-center");
magnificationContainer.style.height = "auto";
magnificationContainer.style.background = "transparent";
toggle.title = "Expand";
toggle.firstChild.textContent = "▴";
} else {
header.style.position = "absolute";
header.style.left = "-10px";
header.style.top = "-15px";
header.style.right = "-10px";
magnificationContainer.classList.add("pt-2", "bg-base-200", "rounded-lg", "items-stretch");
magnificationContainer.classList.remove("items-center");
magnificationContainer.style.height = `${scalebar.magnificationContainerHeight}px`;
magnificationContainer.style.background = "";
toggle.title = "Minimize";
toggle.firstChild.textContent = "▾";
}
scalebar.refreshHandler?.();
};
toggle.addEventListener("click", () => {
scalebar._ui.collapsed = !scalebar._ui.collapsed;
scalebar._applyCollapsed();
});
if (scalebar._ui.collapsed) scalebar._applyCollapsed();
}
class ViewportSyncAPI {
constructor(viewer) {
this.master = viewer;
this.enabled = false;
this.points = new Map();
this.transforms = new Map();
this.context = 0;
}
isEnabled() { return this.enabled; }
_getSession() {
if (!ViewportSyncAPI._session) {
ViewportSyncAPI._session = {
context: this.context || 0,
leaderId: null,
leaderPts: null,
transforms: {},
flipParity: {}
};
}
const S = ViewportSyncAPI._session;
S.transforms ||= {};
S.flipParity ||= {};
if (typeof S.context !== "number") S.context = this.context || 0;
return S;
}
_findViewerById(viewerId) {
return (window.VIEWER_MANAGER?.viewers || []).find(v => v?.uniqueId === viewerId) || null;
}
_getLinkedPeers() {
const S = this._getSession();
return OpenSeadragon.Tools?._linkContexts?.[S.context]?.subscribed || [];
}
_identityTransform() {
return {
A: [1, 0, 0, 1],
b: { x: 0, y: 0 },
invA: [1, 0, 0, 1],
scale: 1,
rotDeg: 0
};
}
_normalizeTransform(t) {
if (!t) return null;
const invA = t.invA || this._invert2x2(t.A);
if (!invA) return null;
return {
A: t.A,
b: { x: t.b.x, y: t.b.y },
invA,
scale: t.scale || 1,
rotDeg: t.rotDeg || 0
};
}
_storeViewerTransform(viewerId, t) {
const S = this._getSession();
const normalized = this._normalizeTransform(t);
if (!normalized) throw new Error("Invalid calibration transform");
S.transforms[viewerId] = normalized;
this.transforms.set(viewerId, normalized);
return normalized;
}
_getViewerTransform(viewerId) {
const S = this._getSession();
const t = S.transforms?.[viewerId];
return this._normalizeTransform(t);
}
_setFlipParity(viewerId, parity) {
this._getSession().flipParity[viewerId] = !!parity;
}
_getFlipParity(viewerId) {
return !!this._getSession().flipParity?.[viewerId];
}
_xorBool(...vals) {
return vals.reduce((acc, v) => acc !== !!v, false);
}
async enable() {
if (this.enabled) return;
const S = this._getSession();
const selfId = this.master.uniqueId;
if (!S.leaderId) {
this.__ui?.setProgress?.("0/3");
const refPts = await this.calibrateViewer(this.master);
S.leaderId = selfId;
S.leaderPts = refPts;
this.points.set(selfId, refPts);
this._storeViewerTransform(selfId, this._identityTransform());
this._setFlipParity(selfId, false);
} else if (!this._getViewerTransform(selfId)) {
this.__ui?.setProgress?.("0/3");
const tgtPts = await this.calibrateViewer(this.master);
this.points.set(selfId, tgtPts);
const t = this._similarityFrom3(S.leaderPts, tgtPts);
if (!t) throw new Error("Calibration invalid");
this._storeViewerTransform(selfId, t);
const refViewer = this._findViewerById(S.leaderId);
const refFlip = refViewer?.viewport?.getFlip?.() ?? false;
const selfFlip = this.master?.viewport?.getFlip?.() ?? false;
this._setFlipParity(selfId, this._xorBool(selfFlip, refFlip));
}
this.master.tools.link(S.context, (sourceViewer, sourceState) => {
return this._mapStateBetweenViewers(sourceViewer, this.master, sourceState);
});
this.enabled = true;
const peers = this._getLinkedPeers().filter(v => v && v !== this.master);
const sourceViewer = peers[0] || this._findViewerById(S.leaderId);
if (sourceViewer && sourceViewer !== this.master) {
this._alignTargetToSourceNow(sourceViewer, this.master);
}
this.master.__syncToolChanged?.();
}
disable() {
if (!this.enabled) return;
const S = this._getSession();
this.master.tools?.unlink?.(S.context);
this.enabled = false;
this.master.__syncToolChanged?.();
}
resetViewer(viewerId = this.master.uniqueId) {
const S = this._getSession();
if (S.leaderId === viewerId) {
this.resetSession();
return;
}
delete S.transforms?.[viewerId];
delete S.flipParity?.[viewerId];
this.transforms.delete(viewerId);
this.points.delete(viewerId);
if (this.enabled) {
this.master.tools?.unlink?.(S.context);
this.enabled = false;
}
this.master.__syncToolChanged?.();
}
resetSession() {
const S = this._getSession();
const peers = this._getLinkedPeers();
for (const v of peers) {
v?.tools?.unlink?.(S.context);
const peerApi = v?.scalebar?.ViewportSyncAPI;
if (peerApi) {
peerApi.enabled = false;
peerApi.transforms.clear();
peerApi.points.clear();
}
v?.__syncToolChanged?.();
}
ViewportSyncAPI._session = null;
this.transforms.clear();
this.points.clear();
this.enabled = false;
this.master.__syncToolChanged?.();
}
async calibrateViewer(viewer) {
return new Promise((resolve, reject) => {
const settle = (fn, arg) => {
this._activeCalibrationCancel = null;
fn(arg);
};
const cleanupPick = this.pickThreePoints(
(pts) => {
Dialogs.show("Calibration saved", 1200, Dialogs.MSG_SUCCESS);
this.__ui?.setProgress?.("");
settle(resolve, pts);
},
() => {
this.__ui?.setProgress?.("");
settle(reject, new Error("Calibration cancelled"));
},
(current, total) => {
this.__ui?.setProgress?.(`${current}/${total}`);
},
{ timeoutMs: 15000 }
);
this._activeCalibrationCancel = cleanupPick;
});
}
cancelCalibration() {
const cancel = this._activeCalibrationCancel;
if (typeof cancel === "function") {
this._activeCalibrationCancel = null;
cancel();
return true;
}
return false;
}
pickThreePoints(onDone, onCancel, onProgress, opts = {}) {
const viewer = this.master;
const pts = [];
const overlays = [];
const total = 3;
const timeoutMs = Math.max(1000, opts.timeoutMs ?? 30000);
let timeoutRef = null;
const removeAll = () => {
for (const o of overlays) {
try { viewer.removeOverlay(o.el); } catch {}
}
overlays.length = 0;
};
const cancel = () => {
viewer.removeHandler("canvas-click", handler);
window.removeEventListener("keydown", keyHandler, true);
if (timeoutRef) clearTimeout(timeoutRef);
removeAll();
onCancel?.();
};
const finish = () => {
viewer.removeHandler("canvas-click", handler);
window.removeEventListener("keydown", keyHandler, true);
if (timeoutRef) clearTimeout(timeoutRef);
removeAll();
onDone?.(pts);
};
const restartTimeout = () => {
if (timeoutRef) clearTimeout(timeoutRef);
timeoutRef = setTimeout(() => {
Dialogs?.show?.("Sync calibration timed out", 1600, Dialogs.MSG_WARN);
cancel();
}, timeoutMs);
};
const addMarker = (imgPt, item) => {
const vpPt = item.imageToViewportCoordinates(
new OpenSeadragon.Point(imgPt.x, imgPt.y)
);
const el = document.createElement("div");
el.className =
"w-3 h-3 -translate-x-1/2 -translate-y-1/2 rounded-full " +
"bg-error ring-2 ring-base-100 shadow pointer-events-none";
el.style.display = "grid";
el.style.placeItems = "center";
el.style.fontSize = "10px";
el.style.fontWeight = "700";
el.style.color = "white";
el.textContent = String(pts.length);
viewer.addOverlay({
element: el,
location: vpPt,
placement: OpenSeadragon.Placement.CENTER
});
overlays.push({ el, img: imgPt });
};
const removeLast = () => {
if (!pts.length) return;
pts.pop();
const o = overlays.pop();
if (o?.el) {
try { viewer.removeOverlay(o.el); } catch {}
}
onProgress?.(pts.length, total);
restartTimeout();
};
const keyHandler = (ev) => {
if (ev.key === "Escape") {
ev.preventDefault();
cancel();
} else if (ev.key === "Backspace") {
ev.preventDefault();
removeLast();
}
};
const handler = (e) => {
if (!e?.position) return;
const item = viewer.world.getItemAt(0);
if (!item) return;
const vp = viewer.viewport.pointFromPixel(e.position);
const img = item.viewportToImageCoordinates(vp);
if (!isFinite(img.x) || !isFinite(img.y)) return;
pts.push({ x: img.x, y: img.y });
onProgress?.(pts.length, total);
addMarker(img, item);
restartTimeout();
if (pts.length >= total) finish();
e.preventDefaultAction = true;
};
Dialogs?.show?.("Click three points on the slide to calibrate sync.", 5000, Dialogs.MSG_INFO);
onProgress?.(0, total);
viewer.addHandler("canvas-click", handler);
window.addEventListener("keydown", keyHandler, true);
restartTimeout();
return cancel;
}
_mapImagePointToReference(imgPt, t) {
if (!t) return null;
const shifted = { x: imgPt.x - t.b.x, y: imgPt.y - t.b.y };
return this._mul2x2_vec(t.invA, shifted);
}
_mapImagePointFromReference(refPt, t) {
if (!t) return null;
const mapped = this._mul2x2_vec(t.A, refPt);
return { x: mapped.x + t.b.x, y: mapped.y + t.b.y };
}
_mapStateBetweenViewers(sourceViewer, targetViewer, sourceState) {
if (!sourceViewer || !targetViewer || !sourceState) return null;
if (sourceViewer === targetViewer) return sourceState;
const sourceItem = sourceViewer.world.getItemAt(0);
const targetItem = targetViewer.world.getItemAt(0);
if (!sourceItem || !targetItem) return null;
const sourceT = this._getViewerTransform(sourceViewer.uniqueId);
const targetT = this._getViewerTransform(targetViewer.uniqueId);
if (!sourceT || !targetT) return null;
const sourceCenterImg = sourceItem.viewportToImageCoordinates(sourceState.center);
if (!isFinite(sourceCenterImg.x) || !isFinite(sourceCenterImg.y)) return null;
const refCenterImg =
sourceViewer.uniqueId === this._getSession().leaderId
? { x: sourceCenterImg.x, y: sourceCenterImg.y }
: this._mapImagePointToReference(sourceCenterImg, sourceT);
if (!refCenterImg || !isFinite(refCenterImg.x) || !isFinite(refCenterImg.y)) return null;
const targetCenterImg =
targetViewer.uniqueId === this._getSession().leaderId
? refCenterImg
: this._mapImagePointFromReference(refCenterImg, targetT);
if (!targetCenterImg || !isFinite(targetCenterImg.x) || !isFinite(targetCenterImg.y)) return null;
const targetCenterVp = targetItem.imageToViewportCoordinates(
new OpenSeadragon.Point(targetCenterImg.x, targetCenterImg.y)
);
if (!isFinite(targetCenterVp.x) || !isFinite(targetCenterVp.y)) return null;
const zoom = sourceState.zoom * ((sourceT.scale || 1) / (targetT.scale || 1));
const rotation = sourceState.rotation + (sourceT.rotDeg || 0) - (targetT.rotDeg || 0);
const flip = this._xorBool(
!!sourceState.flip,
this._getFlipParity(sourceViewer.uniqueId),
this._getFlipParity(targetViewer.uniqueId)
);
return {
center: targetCenterVp,
zoom,
rotation,
flip
};
}
_alignTargetToSourceNow(sourceViewer, targetViewer) {
const sourceState = sourceViewer?.tools?.readViewportState?.();
const mappedState = this._mapStateBetweenViewers(sourceViewer, targetViewer, sourceState);
if (mappedState) {
targetViewer.tools.applyViewportState(mappedState);
}
}
_invert2x2(m) {
const [a,b,c,d] = m;
const det = a*d - b*c;
if (!isFinite(det) || Math.abs(det) < 1e-12) return null;
const invDet = 1 / det;
return [ d*invDet, -b*invDet, -c*invDet, a*invDet ];
}
_mul2x2(a, b) {
return [
a[0]*b[0] + a[1]*b[2],
a[0]*b[1] + a[1]*b[3],
a[2]*b[0] + a[3]*b[2],
a[2]*b[1] + a[3]*b[3],
];
}
_mul2x2_vec(m, v) {
return { x: m[0]*v.x + m[1]*v.y, y: m[2]*v.x + m[3]*v.y };
}
_similarityFrom3(refPts, tgtPts) {
const rc = {
x: (refPts[0].x + refPts[1].x + refPts[2].x) / 3,
y: (refPts[0].y + refPts[1].y + refPts[2].y) / 3
};
const tc = {
x: (tgtPts[0].x + tgtPts[1].x + tgtPts[2].x) / 3,
y: (tgtPts[0].y + tgtPts[1].y + tgtPts[2].y) / 3
};
let a = 0, b = 0, denom = 0;
for (let i = 0; i < 3; i++) {
const rx = refPts[i].x - rc.x;
const ry = refPts[i].y - rc.y;
const tx = tgtPts[i].x - tc.x;
const ty = tgtPts[i].y - tc.y;
a += rx * tx + ry * ty;
b += rx * ty - ry * tx;
denom += rx * rx + ry * ry;
}
if (!isFinite(denom) || denom < 1e-12) return null;
const norm = Math.hypot(a, b);
if (!isFinite(norm) || norm < 1e-12) return null;
const scale = norm / denom;
const cos = a / norm;
const sin = b / norm;
const A = [
scale * cos, -scale * sin,
scale * sin, scale * cos
];
const Arc = {
x: A[0] * rc.x + A[1] * rc.y,
y: A[2] * rc.x + A[3] * rc.y
};
const t = {
x: tc.x - Arc.x,
y: tc.y - Arc.y
};
const rotDeg = Math.atan2(A[2], A[0]) * 180 / Math.PI;
const invA = this._invert2x2(A);
if (!invA) return null;
return { A, b: t, invA, scale, rotDeg };
}
}
}(OpenSeadragon));