246 lines
8.6 KiB
Svelte
246 lines
8.6 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
|
|
const RULER_THICKNESS = 16;
|
|
const MAJOR_MARK_THICKNESS = 16;
|
|
const MINOR_MARK_THICKNESS = 6;
|
|
const MICRO_MARK_THICKNESS = 3;
|
|
const TAU = 2 * Math.PI;
|
|
|
|
type RulerDirection = "Horizontal" | "Vertical";
|
|
|
|
export let direction: RulerDirection = "Vertical";
|
|
export let originX: number;
|
|
export let originY: number;
|
|
export let tilt: number;
|
|
export let flip: boolean = false;
|
|
export let numberInterval: number;
|
|
export let majorMarkSpacing: number;
|
|
export let minorDivisions = 5;
|
|
export let microDivisions = 2;
|
|
export let cursorPosition: { x: number; y: number } | undefined = undefined;
|
|
|
|
let rulerInput: HTMLDivElement | undefined;
|
|
let rulerLength = 0;
|
|
let svgBounds = { width: "0px", height: "0px" };
|
|
|
|
type Axis = { sign: number; vec: [number, number] };
|
|
|
|
$: axes = computeAxes(tilt);
|
|
$: isHorizontal = direction === "Horizontal";
|
|
$: trackedAxis = isHorizontal ? axes.horiz : axes.vert;
|
|
$: otherAxis = isHorizontal ? axes.vert : axes.horiz;
|
|
$: crossAxisDirection = flipVector(otherAxis.vec, flip);
|
|
$: stretchFactor = 1 / Math.max(Math.abs(isHorizontal ? trackedAxis.vec[0] : trackedAxis.vec[1]), 1e-10);
|
|
$: stretchedSpacing = majorMarkSpacing * stretchFactor;
|
|
$: effectiveOrigin = projectOntoRuler(direction, originX, originY, crossAxisDirection);
|
|
$: svgPath = computeSvgPath(direction, effectiveOrigin, stretchedSpacing, stretchFactor, minorDivisions, microDivisions, rulerLength, crossAxisDirection);
|
|
$: svgTexts = computeSvgTexts(direction, effectiveOrigin, stretchedSpacing, numberInterval, rulerLength, trackedAxis, crossAxisDirection);
|
|
$: cursorIndicatorPath = computeCursorIndicator(direction, cursorPosition, crossAxisDirection);
|
|
|
|
function computeAxes(tilt: number): { horiz: Axis; vert: Axis } {
|
|
const normTilt = ((tilt % TAU) + TAU) % TAU;
|
|
const octant = Math.floor((normTilt + Math.PI / 4) / (Math.PI / 2)) % 4;
|
|
|
|
const [c, s] = [Math.cos(tilt), Math.sin(tilt)];
|
|
const posX: Axis = { sign: 1, vec: [c, s] };
|
|
const posY: Axis = { sign: 1, vec: [-s, c] };
|
|
const negX: Axis = { sign: -1, vec: [-c, -s] };
|
|
const negY: Axis = { sign: -1, vec: [s, -c] };
|
|
|
|
if (octant === 0) return { horiz: posX, vert: posY };
|
|
if (octant === 1) return { horiz: negY, vert: posX };
|
|
if (octant === 2) return { horiz: negX, vert: negY };
|
|
return { horiz: posY, vert: negX };
|
|
}
|
|
|
|
function flipVector(vec: [number, number], flipped: boolean): [number, number] {
|
|
return flipped ? [-vec[0], vec[1]] : vec;
|
|
}
|
|
|
|
function projectOntoRuler(direction: RulerDirection, x: number, y: number, vec: [number, number]): number {
|
|
const [vx, vy] = vec;
|
|
if (direction === "Horizontal") return Math.abs(vy) < 1e-10 ? x : x - y * (vx / vy);
|
|
return Math.abs(vx) < 1e-10 ? y : y - x * (vy / vx);
|
|
}
|
|
|
|
function tickMarkGeometry(direction: RulerDirection, vx: number, vy: number): { dx: number; dy: number; sxBase: number; syBase: number } {
|
|
const reversal = direction === "Horizontal" ? (vy > 0 ? -1 : 1) : vx > 0 ? -1 : 1;
|
|
return {
|
|
dx: vx * reversal,
|
|
dy: vy * reversal,
|
|
sxBase: direction === "Horizontal" ? 0 : RULER_THICKNESS,
|
|
syBase: direction === "Horizontal" ? RULER_THICKNESS : 0,
|
|
};
|
|
}
|
|
|
|
function computeSvgPath(
|
|
direction: RulerDirection,
|
|
effectiveOrigin: number,
|
|
stretchedSpacing: number,
|
|
stretchFactor: number,
|
|
minorDivisions: number,
|
|
microDivisions: number,
|
|
rulerLength: number,
|
|
crossAxisDirection: [number, number],
|
|
): string {
|
|
const adaptive = stretchFactor > 1.3 ? { minor: minorDivisions, micro: 1 } : { minor: minorDivisions, micro: microDivisions };
|
|
const divisions = stretchedSpacing / adaptive.minor / adaptive.micro;
|
|
const majorMarksFrequency = adaptive.minor * adaptive.micro;
|
|
const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing;
|
|
|
|
const { dx, dy, sxBase, syBase } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
|
|
|
|
let path = "";
|
|
let i = 0;
|
|
for (let location = shiftedOffsetStart; location < rulerLength + RULER_THICKNESS; location += divisions) {
|
|
let length;
|
|
if (i % majorMarksFrequency === 0) length = MAJOR_MARK_THICKNESS;
|
|
else if (i % adaptive.micro === 0) length = MINOR_MARK_THICKNESS;
|
|
else length = MICRO_MARK_THICKNESS;
|
|
i += 1;
|
|
|
|
const destination = Math.round(location) + 0.5;
|
|
const [sx, sy] = direction === "Horizontal" ? [destination, syBase] : [sxBase, destination];
|
|
path += `M${sx},${sy}l${dx * length},${dy * length} `;
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
function computeSvgTexts(
|
|
direction: RulerDirection,
|
|
effectiveOrigin: number,
|
|
stretchedSpacing: number,
|
|
numberInterval: number,
|
|
rulerLength: number,
|
|
trackedAxis: Axis,
|
|
crossAxisDirection: [number, number],
|
|
): { transform: string; text: string }[] {
|
|
const isVertical = direction === "Vertical";
|
|
|
|
const { dx: tipDx, dy: tipDy } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
|
|
const forwardTip = isVertical ? -tipDy : tipDx;
|
|
const tiltScale = forwardTip >= 0 ? 1 : 0.5;
|
|
const tipOffsetX = tipDx * MAJOR_MARK_THICKNESS * tiltScale;
|
|
const tipOffsetY = tipDy * MAJOR_MARK_THICKNESS * tiltScale;
|
|
|
|
const shiftedOffsetStart = mod(effectiveOrigin, stretchedSpacing) - stretchedSpacing;
|
|
const increments = Math.round((shiftedOffsetStart - effectiveOrigin) / stretchedSpacing);
|
|
let labelNumber = increments * numberInterval * trackedAxis.sign;
|
|
|
|
const results: { transform: string; text: string }[] = [];
|
|
|
|
for (let location = shiftedOffsetStart; location < rulerLength; location += stretchedSpacing) {
|
|
const destination = Math.round(location);
|
|
const x = isVertical ? 9 : destination + 2 + tipOffsetX;
|
|
const y = isVertical ? destination + 1 + tipOffsetY : 9;
|
|
|
|
let transform = `translate(${x} ${y})`;
|
|
if (isVertical) transform += " rotate(270)";
|
|
|
|
const num = Math.abs(labelNumber) < 1e-9 ? 0 : labelNumber;
|
|
const text = numberInterval >= 1 ? `${num}` : num.toFixed(Math.abs(Math.log10(numberInterval))).replace(/\.0+$/, "");
|
|
|
|
results.push({ transform, text });
|
|
|
|
labelNumber += numberInterval * trackedAxis.sign;
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
function computeCursorIndicator(direction: RulerDirection, cursor: { x: number; y: number } | undefined, crossAxisDirection: [number, number]): string {
|
|
if (cursor === undefined) return "";
|
|
|
|
const projected = projectOntoRuler(direction, cursor.x, cursor.y, crossAxisDirection);
|
|
const { dx, dy, sxBase, syBase } = tickMarkGeometry(direction, crossAxisDirection[0], crossAxisDirection[1]);
|
|
|
|
// Scale the line so it spans the full ruler bar thickness
|
|
const thicknessComponent = Math.abs(direction === "Horizontal" ? dy : dx);
|
|
const length = thicknessComponent < 1e-10 ? RULER_THICKNESS : RULER_THICKNESS / thicknessComponent;
|
|
|
|
const destination = Math.round(projected) + 0.5;
|
|
const [sx, sy] = direction === "Horizontal" ? [destination, syBase] : [sxBase, destination];
|
|
return `M${sx},${sy}l${dx * length},${dy * length}`;
|
|
}
|
|
|
|
export function resize() {
|
|
if (!rulerInput) return;
|
|
|
|
const isVertical = direction === "Vertical";
|
|
|
|
const newLength = isVertical ? rulerInput.clientHeight : rulerInput.clientWidth;
|
|
const roundedUp = (Math.floor(newLength / stretchedSpacing) + 2) * stretchedSpacing;
|
|
|
|
if (roundedUp !== rulerLength) {
|
|
rulerLength = roundedUp;
|
|
const thickness = `${RULER_THICKNESS}px`;
|
|
const length = `${roundedUp}px`;
|
|
svgBounds = isVertical ? { width: thickness, height: length } : { width: length, height: thickness };
|
|
}
|
|
}
|
|
|
|
// Modulo function that works for negative numbers, unlike the JS `%` operator
|
|
function mod(n: number, m: number): number {
|
|
const remainder = n % m;
|
|
return Math.floor(remainder >= 0 ? remainder : remainder + m);
|
|
}
|
|
|
|
onMount(resize);
|
|
</script>
|
|
|
|
<div class={`ruler-input ${direction.toLowerCase()}`} bind:this={rulerInput}>
|
|
<svg style:width={svgBounds.width} style:height={svgBounds.height}>
|
|
<path d={svgPath} />
|
|
{#each svgTexts as svgText}
|
|
<text transform={svgText.transform}>{svgText.text}</text>
|
|
{/each}
|
|
{#if cursorIndicatorPath}
|
|
<path class="cursor-indicator" d={cursorIndicatorPath} />
|
|
{/if}
|
|
</svg>
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.ruler-input {
|
|
flex: 1 1 100%;
|
|
background: var(--color-2-mildblack);
|
|
overflow: hidden;
|
|
position: relative;
|
|
box-sizing: border-box;
|
|
|
|
&.horizontal {
|
|
height: 16px;
|
|
border-bottom: 1px solid var(--color-5-dullgray);
|
|
}
|
|
|
|
&.vertical {
|
|
width: 16px;
|
|
border-right: 1px solid var(--color-5-dullgray);
|
|
|
|
svg text {
|
|
text-anchor: end;
|
|
}
|
|
}
|
|
|
|
svg {
|
|
position: absolute;
|
|
|
|
path {
|
|
stroke-width: 1px;
|
|
stroke: var(--color-5-dullgray);
|
|
|
|
&.cursor-indicator {
|
|
stroke: var(--color-8-uppergray);
|
|
}
|
|
}
|
|
|
|
text {
|
|
font-size: 12px;
|
|
fill: var(--color-8-uppergray);
|
|
}
|
|
}
|
|
}
|
|
</style>
|