From 109917afde32e7bb85b1affdaaea50824f99f023 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Tue, 23 Jun 2026 19:28:11 +0500 Subject: [PATCH] feat: initial release --- index.html | 68 +++++++++++ script.js | 348 +++++++++++++++++++++++++++++++++++++++++++++++++++++ style.css | 288 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 704 insertions(+) create mode 100644 index.html create mode 100644 script.js create mode 100644 style.css diff --git a/index.html b/index.html new file mode 100644 index 0000000..6bf5ede --- /dev/null +++ b/index.html @@ -0,0 +1,68 @@ + + + + + + CSS Matrix3D Perspective Calculator + + + +
+

CSS Matrix3D Perspective Calculator

+

Задайте 4 точки на поле и размер виджета — получите CSS matrix3d() для перспективного отображения

+
+ +
+
+
+

Preview — перетаскивайте углы

+ +
+
+

Live Preview

+
+
+
+ WIDGET +
+
+
+
+
+ +
+
+

Основное поле

+
+ + +
+
+ +
+

Виджет

+
+ + +
+
+ +
+

Углы четырёхугольника

+
+
+ +
+

CSS Output

+ +
+ + +
+
+
+
+ + + + \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..e279c14 --- /dev/null +++ b/script.js @@ -0,0 +1,348 @@ +(() => { + const LABELS = ['TL', 'TR', 'BR', 'BL']; + + const defaultState = { + fieldW: 1920, fieldH: 1080, + widgetW: 400, widgetH: 300, + corners: [ + { x: 300, y: 200 }, + { x: 1620, y: 150 }, + { x: 1580, y: 920 }, + { x: 340, y: 950 } + ] + }; + + const state = { + fieldW: 1920, fieldH: 1080, + widgetW: 400, widgetH: 300, + corners: [ + { x: 300, y: 200 }, + { x: 1620, y: 150 }, + { x: 1580, y: 920 }, + { x: 340, y: 950 } + ] + }; + + const $ = id => document.getElementById(id); + const svg = $('svg'); + + // ===== Homography math ===== + function computeHomography() { + const w = state.widgetW, h = state.widgetH; + if (w <= 0 || h <= 0) return null; + const [p0, p1, p2, p3] = state.corners; + + const a11 = p2.x - p1.x, a12 = p2.x - p3.x; + const b1 = p1.x + p3.x - p0.x - p2.x; + const a21 = p2.y - p1.y, a22 = p2.y - p3.y; + const b2 = p1.y + p3.y - p0.y - p2.y; + + const D = a11 * a22 - a12 * a21; + if (Math.abs(D) < 1e-10) return null; + + const A = (b1 * a22 - b2 * a12) / D; + const B = (a11 * b2 - a21 * b1) / D; + + return { + h00: (p1.x * (A + 1) - p0.x) / w, + h10: (p1.y * (A + 1) - p0.y) / w, + h01: (p3.x * (B + 1) - p0.x) / h, + h11: (p3.y * (B + 1) - p0.y) / h, + h02: p0.x, + h12: p0.y, + h20: A / w, + h21: B / h, + h22: 1 + }; + } + + function toMatrix3dCSS(H) { + if (!H) return '/* вырожденная конфигурация */'; + const n = v => parseFloat(v.toFixed(8)); + return [ + 'transform-origin: 0 0;', + 'transform: matrix3d(', + ` ${n(H.h00)}, ${n(H.h10)}, 0, ${n(H.h20)},`, + ` ${n(H.h01)}, ${n(H.h11)}, 0, ${n(H.h21)},`, + ` 0, 0, 1, 0,`, + ` ${n(H.h02)}, ${n(H.h12)}, 0, ${n(H.h22)}`, + ');' + ].join('\n'); + } + + // ===== SVG rendering ===== + function gridInterval(maxDim) { + if (maxDim <= 500) return 50; + if (maxDim <= 1000) return 100; + if (maxDim <= 2000) return 200; + return 500; + } + + function renderSVG() { + svg.setAttribute('viewBox', `0 0 ${state.fieldW} ${state.fieldH}`); + svg.innerHTML = ''; + + const ns = 'http://www.w3.org/2000/svg'; + + const rect = document.createElementNS(ns, 'rect'); + rect.setAttribute('width', state.fieldW); + rect.setAttribute('height', state.fieldH); + rect.setAttribute('fill', '#0a0e14'); + svg.appendChild(rect); + + const step = gridInterval(Math.max(state.fieldW, state.fieldH)); + const gridG = document.createElementNS(ns, 'g'); + for (let x = step; x < state.fieldW; x += step) { + const line = document.createElementNS(ns, 'line'); + line.setAttribute('x1', x); line.setAttribute('y1', 0); + line.setAttribute('x2', x); line.setAttribute('y2', state.fieldH); + line.setAttribute('stroke', 'rgba(255,255,255,0.06)'); + line.setAttribute('stroke-width', 1); + gridG.appendChild(line); + } + for (let y = step; y < state.fieldH; y += step) { + const line = document.createElementNS(ns, 'line'); + line.setAttribute('x1', 0); line.setAttribute('y1', y); + line.setAttribute('x2', state.fieldW); line.setAttribute('y2', y); + line.setAttribute('stroke', 'rgba(255,255,255,0.06)'); + line.setAttribute('stroke-width', 1); + gridG.appendChild(line); + } + svg.appendChild(gridG); + + const widgetRect = document.createElementNS(ns, 'rect'); + widgetRect.setAttribute('x', 0); + widgetRect.setAttribute('y', 0); + widgetRect.setAttribute('width', state.widgetW); + widgetRect.setAttribute('height', state.widgetH); + widgetRect.setAttribute('fill', 'none'); + widgetRect.setAttribute('stroke', 'rgba(88,166,255,0.2)'); + widgetRect.setAttribute('stroke-width', 2); + widgetRect.setAttribute('stroke-dasharray', '12,8'); + svg.appendChild(widgetRect); + + const poly = document.createElementNS(ns, 'polygon'); + const pts = state.corners.map(c => `${c.x},${c.y}`).join(' '); + poly.setAttribute('points', pts); + poly.setAttribute('fill', 'rgba(88,166,255,0.1)'); + poly.setAttribute('stroke', '#58a6ff'); + poly.setAttribute('stroke-width', 3); + poly.setAttribute('stroke-linejoin', 'round'); + svg.appendChild(poly); + + state.corners.forEach((c, i) => { + const g = document.createElementNS(ns, 'g'); + + const circle = document.createElementNS(ns, 'circle'); + circle.setAttribute('cx', c.x); + circle.setAttribute('cy', c.y); + circle.setAttribute('r', 10); + circle.setAttribute('fill', '#58a6ff'); + circle.setAttribute('stroke', '#ffffff'); + circle.setAttribute('stroke-width', 2.5); + circle.setAttribute('class', 'corner-circle'); + circle.dataset.index = i; + g.appendChild(circle); + + const label = document.createElementNS(ns, 'text'); + const lx = c.x + (i === 0 || i === 3 ? -30 : 20); + const ly = c.y + (i < 2 ? -18 : 28); + label.setAttribute('x', lx); + label.setAttribute('y', ly); + label.setAttribute('class', 'corner-label'); + label.setAttribute('text-anchor', 'middle'); + label.textContent = LABELS[i]; + g.appendChild(label); + + const coord = document.createElementNS(ns, 'text'); + coord.setAttribute('x', lx); + coord.setAttribute('y', ly + 18); + coord.setAttribute('class', 'corner-coord'); + coord.setAttribute('text-anchor', 'middle'); + coord.textContent = `(${c.x}, ${c.y})`; + g.appendChild(coord); + + svg.appendChild(g); + }); + } + + // ===== Drag handling ===== + let dragIndex = -1; + + function svgPoint(clientX, clientY) { + const pt = svg.createSVGPoint(); + pt.x = clientX; + pt.y = clientY; + return pt.matrixTransform(svg.getScreenCTM().inverse()); + } + + function onPointerDown(e) { + const target = e.target.closest('.corner-circle'); + if (!target) return; + e.preventDefault(); + dragIndex = parseInt(target.dataset.index); + target.classList.add('dragging'); + document.addEventListener('mousemove', onPointerMove); + document.addEventListener('mouseup', onPointerUp); + document.addEventListener('touchmove', onPointerMove, { passive: false }); + document.addEventListener('touchend', onPointerUp); + } + + function onPointerMove(e) { + if (dragIndex < 0) return; + e.preventDefault(); + const cx = e.touches ? e.touches[0].clientX : e.clientX; + const cy = e.touches ? e.touches[0].clientY : e.clientY; + const pt = svgPoint(cx, cy); + state.corners[dragIndex].x = Math.round(Math.max(0, Math.min(state.fieldW, pt.x))); + state.corners[dragIndex].y = Math.round(Math.max(0, Math.min(state.fieldH, pt.y))); + updateAll(); + } + + function onPointerUp() { + dragIndex = -1; + document.querySelectorAll('.corner-circle.dragging').forEach(el => el.classList.remove('dragging')); + document.removeEventListener('mousemove', onPointerMove); + document.removeEventListener('mouseup', onPointerUp); + document.removeEventListener('touchmove', onPointerMove); + document.removeEventListener('touchend', onPointerUp); + } + + svg.addEventListener('mousedown', onPointerDown); + svg.addEventListener('touchstart', onPointerDown, { passive: false }); + + // ===== Live preview ===== + function updateLivePreview() { + const container = $('fieldContainer'); + const cw = container.clientWidth; + if (cw <= 0) return; + + const scale = cw / state.fieldW; + const field = $('field'); + field.style.width = state.fieldW + 'px'; + field.style.height = state.fieldH + 'px'; + field.style.transform = `scale(${scale})`; + container.style.height = (state.fieldH * scale) + 'px'; + + const widget = $('widget'); + widget.style.width = state.widgetW + 'px'; + widget.style.height = state.widgetH + 'px'; + + const H = computeHomography(); + if (H) { + const n = v => parseFloat(v.toFixed(6)); + widget.style.transform = `matrix3d(${n(H.h00)},${n(H.h10)},0,${n(H.h20)},${n(H.h01)},${n(H.h11)},0,${n(H.h21)},0,0,1,0,${n(H.h02)},${n(H.h12)},0,${n(H.h22)})`; + widget.style.opacity = '1'; + } else { + widget.style.transform = 'none'; + widget.style.opacity = '0.3'; + } + } + + // ===== Corner inputs ===== + function buildCornerInputs() { + const container = $('cornerInputs'); + container.innerHTML = ''; + LABELS.forEach((label, i) => { + const row = document.createElement('div'); + row.className = 'corner-input-row'; + row.innerHTML = ` + ${label} + + + `; + container.appendChild(row); + }); + } + + function syncCornerInputs() { + state.corners.forEach((c, i) => { + const xi = $(`c${i}x`); + const yi = $(`c${i}y`); + if (document.activeElement !== xi) xi.value = c.x; + if (document.activeElement !== yi) yi.value = c.y; + }); + } + + function readCornerInputs() { + state.corners.forEach((c, i) => { + const x = parseInt($(`c${i}x`).value); + const y = parseInt($(`c${i}y`).value); + if (!isNaN(x)) c.x = x; + if (!isNaN(y)) c.y = y; + }); + } + + // ===== Update all ===== + function updateAll() { + renderSVG(); + updateLivePreview(); + syncCornerInputs(); + updateCSSOutput(); + } + + function updateCSSOutput() { + const H = computeHomography(); + $('cssOutput').value = toMatrix3dCSS(H); + } + + // ===== Input events ===== + $('inpFieldW').addEventListener('input', () => { + const v = parseInt($('inpFieldW').value); + if (v > 0) { state.fieldW = v; updateAll(); } + }); + $('inpFieldH').addEventListener('input', () => { + const v = parseInt($('inpFieldH').value); + if (v > 0) { state.fieldH = v; updateAll(); } + }); + $('inpWidgetW').addEventListener('input', () => { + const v = parseInt($('inpWidgetW').value); + if (v > 0) { state.widgetW = v; updateAll(); } + }); + $('inpWidgetH').addEventListener('input', () => { + const v = parseInt($('inpWidgetH').value); + if (v > 0) { state.widgetH = v; updateAll(); } + }); + + document.addEventListener('input', (e) => { + if (e.target.id && /^c\d[xy]$/.test(e.target.id)) { + readCornerInputs(); + updateAll(); + } + }); + + // ===== Copy ===== + $('copyBtn').addEventListener('click', () => { + const text = $('cssOutput').value; + navigator.clipboard.writeText(text).then(() => { + const btn = $('copyBtn'); + const orig = btn.textContent; + btn.textContent = 'Скопировано!'; + setTimeout(() => btn.textContent = orig, 1500); + }); + }); + + // ===== Reset ===== + $('resetBtn').addEventListener('click', () => { + state.fieldW = defaultState.fieldW; + state.fieldH = defaultState.fieldH; + state.widgetW = defaultState.widgetW; + state.widgetH = defaultState.widgetH; + defaultState.corners.forEach((c, i) => { + state.corners[i] = { x: c.x, y: c.y }; + }); + $('inpFieldW').value = state.fieldW; + $('inpFieldH').value = state.fieldH; + $('inpWidgetW').value = state.widgetW; + $('inpWidgetH').value = state.widgetH; + buildCornerInputs(); + updateAll(); + }); + + // ===== Resize ===== + window.addEventListener('resize', updateLivePreview); + + // ===== Init ===== + buildCornerInputs(); + updateAll(); +})(); \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..22b70e0 --- /dev/null +++ b/style.css @@ -0,0 +1,288 @@ +:root { + --bg: #0d1117; + --panel: #161b22; + --border: #30363d; + --text: #c9d1d9; + --heading: #e6edf3; + --accent: #58a6ff; + --accent-dim: rgba(88, 166, 255, 0.12); + --input-bg: #0d1117; + --btn-bg: #238636; + --btn-hover: #2ea043; + --widget-bg: #1a1a3e; + --widget-grid: rgba(88, 166, 255, 0.15); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--text); + min-height: 100vh; +} + +header { + padding: 16px 24px; + border-bottom: 1px solid var(--border); +} +header h1 { + font-size: 18px; + color: var(--heading); + font-weight: 600; +} +header p { + font-size: 13px; + color: #8b949e; + margin-top: 4px; +} + +main { + display: grid; + grid-template-columns: 1fr 340px; + gap: 16px; + padding: 16px 24px; + margin: 0 auto; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; +} +.panel h2 { + font-size: 13px; + font-weight: 600; + color: #8b949e; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 12px; +} + +.left-col { + display: flex; + flex-direction: column; + gap: 16px; +} + +.right-col { + display: flex; + flex-direction: column; + gap: 16px; +} + +#svg { + width: 100%; + display: block; + border-radius: 4px; + background: #0a0e14; + user-select: none; + -webkit-user-select: none; +} + +.corner-circle { + cursor: grab; + filter: drop-shadow(0 0 4px rgba(88, 166, 255, 0.5)); + transition: r 0.1s; +} +.corner-circle:hover { + r: 14; +} +.corner-circle.dragging { + cursor: grabbing; + r: 14; +} + +.corner-label { + font-size: 22px; + fill: var(--text); + font-family: monospace; + pointer-events: none; +} + +.corner-coord { + font-size: 18px; + fill: #8b949e; + font-family: monospace; + pointer-events: none; +} + +.field-row { + display: flex; + gap: 12px; + align-items: center; +} +.field-row label { + display: flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: #8b949e; + flex: 1; +} +.field-row label span { + min-width: 14px; +} +.field-row input { + width: 100%; + padding: 6px 8px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--heading); + font-size: 14px; + font-family: monospace; + outline: none; +} +.field-row input:focus { + border-color: var(--accent); +} + +.corner-inputs { + display: flex; + flex-direction: column; + gap: 8px; +} +.corner-input-row { + display: flex; + align-items: center; + gap: 8px; +} +.corner-input-row .label { + font-size: 13px; + font-weight: 600; + color: var(--accent); + min-width: 28px; + font-family: monospace; +} +.corner-input-row label { + display: flex; + align-items: center; + gap: 4px; + font-size: 13px; + color: #8b949e; + flex: 1; +} +.corner-input-row label span { + min-width: 12px; +} +.corner-input-row input { + width: 100%; + padding: 5px 6px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--heading); + font-size: 13px; + font-family: monospace; + outline: none; +} +.corner-input-row input:focus { + border-color: var(--accent); +} + +#cssOutput { + width: 100%; + min-height: 80px; + padding: 10px; + background: var(--input-bg); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--accent); + font-size: 12px; + font-family: "Cascadia Code", "Fira Code", monospace; + line-height: 1.6; + resize: vertical; + outline: none; +} + +.btn-row { + display: flex; + gap: 8px; + margin-top: 8px; +} + +.btn { + padding: 6px 14px; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s; +} +.btn-copy { + background: var(--btn-bg); + color: white; +} +.btn-copy:hover { + background: var(--btn-hover); +} +.btn-reset { + background: var(--border); + color: var(--text); +} +.btn-reset:hover { + background: #484f58; +} + +#fieldContainer { + width: 100%; + overflow: hidden; + border-radius: 4px; + background: #080b10; + border: 2px solid #1a1f2a; + position: relative; +} + +#field { + position: relative; + transform-origin: 0 0; + background: #0c1018; +} + +#widget { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + background-color: var(--widget-bg); + background-image: + linear-gradient(45deg, var(--widget-grid) 25%, transparent 25%, transparent 75%, var(--widget-grid) 75%), + linear-gradient(45deg, var(--widget-grid) 25%, transparent 25%, transparent 75%, var(--widget-grid) 75%); + background-size: 30px 30px; + background-position: + 0 0, + 15px 15px; + border: 2px solid var(--accent); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +#widget span { + color: rgba(255, 255, 255, 0.7); + font-size: 14px; + font-weight: 600; + letter-spacing: 2px; + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + pointer-events: none; +} + +.error-msg { + color: #f85149; + font-size: 12px; + font-style: italic; +} + +@media (max-width: 900px) { + main { + grid-template-columns: 1fr; + } +}