Compare commits
7 Commits
a371f11811
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd7613e2d0 | ||
|
|
01eece9796 | ||
|
|
9616ab749d | ||
|
|
9974d899c2 | ||
|
|
8d46c0f98b | ||
|
|
7a22e40268 | ||
|
|
a51da74cbb |
57
.github/workflows/deploy-pages.yml
vendored
Normal file
57
.github/workflows/deploy-pages.yml
vendored
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
name: Deploy to GitHub Pages
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Allow the deploy job to publish to Pages via OIDC.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
# Only one Pages deployment at a time; cancel superseded runs.
|
||||||
|
concurrency:
|
||||||
|
group: pages
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 10
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Served from a custom domain at the root, so no --base override.
|
||||||
|
- run: pnpm build
|
||||||
|
|
||||||
|
# SPA fallback: vue-router runs in history mode, so unmatched paths
|
||||||
|
# (deep links, refreshes) must fall through to the app instead of 404.
|
||||||
|
- run: cp dist/index.html dist/404.html
|
||||||
|
|
||||||
|
- uses: actions/configure-pages@v5
|
||||||
|
|
||||||
|
- uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: dist
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
steps:
|
||||||
|
- id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
478
DESIGN.md
Normal file
478
DESIGN.md
Normal file
@@ -0,0 +1,478 @@
|
|||||||
|
# Product Radar — Design (QFD)
|
||||||
|
|
||||||
|
Goal-driven design for Product Radar: a solo, browser-local tool for positioning a
|
||||||
|
product against alternatives on a radar chart. This document translates user-facing
|
||||||
|
goals into measurable functions, the components that realise them, and the trade-offs
|
||||||
|
taken. It relies on [CONTEXT.md](./CONTEXT.md) for the ubiquitous language (Radar,
|
||||||
|
Criterion, Profile, Score, Concept) and does not redefine those terms here.
|
||||||
|
|
||||||
|
Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## House of Quality
|
||||||
|
|
||||||
|
```tikz
|
||||||
|
% =====================================================================
|
||||||
|
% QFD "House of Quality" preamble
|
||||||
|
% =====================================================================
|
||||||
|
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
|
||||||
|
|
||||||
|
\newif\ifqfdshowroof \qfdshowrooftrue
|
||||||
|
\newif\ifqfdshowbasement \qfdshowbasementtrue
|
||||||
|
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
|
||||||
|
\newif\ifqfdshowlegend \qfdshowlegendtrue
|
||||||
|
\newif\ifqfdshowimportance \qfdshowimportancetrue
|
||||||
|
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
|
||||||
|
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
|
||||||
|
\newif\ifqfdshowtitle \qfdshowtitletrue
|
||||||
|
|
||||||
|
\def\qfdNW{5}
|
||||||
|
\def\qfdNH{5}
|
||||||
|
\def\qfdWhatW{4.0}
|
||||||
|
\def\qfdImpW{0.9}
|
||||||
|
\def\qfdCmpW{3}
|
||||||
|
\def\qfdHdrH{2.6}
|
||||||
|
\def\qfdBasementN{4}
|
||||||
|
|
||||||
|
\def\qfdWhatsTitle{Customer needs}
|
||||||
|
\def\qfdImpTitle{Imp.\ \%}
|
||||||
|
\def\qfdPerceptionTitle{Comparative evaluation}
|
||||||
|
\def\qfdPoorLabel{poor}
|
||||||
|
\def\qfdExcellentLabel{excellent}
|
||||||
|
\def\qfdAltOneLabel{Our product}
|
||||||
|
\def\qfdAltTwoLabel{Competitor A}
|
||||||
|
\def\qfdAltThreeLabel{Competitor B}
|
||||||
|
\def\qfdRelTitle{Relation}
|
||||||
|
\def\qfdCorrTitle{Correlation}
|
||||||
|
\def\qfdEvalTitle{Evaluation}
|
||||||
|
|
||||||
|
\def\qfdProjectTitle{}
|
||||||
|
\def\qfdConcept{}
|
||||||
|
|
||||||
|
\tikzset{
|
||||||
|
qfdthin/.style ={line width=0.35pt},
|
||||||
|
qfdmed/.style ={line width=0.7pt},
|
||||||
|
qfdstrong/.style={circle, draw, fill=black,
|
||||||
|
minimum size=7pt, inner sep=0pt},
|
||||||
|
qfdmod/.style ={circle, draw,
|
||||||
|
minimum size=7pt, inner sep=0pt, line width=0.8pt},
|
||||||
|
qfdweak/.style ={regular polygon, regular polygon sides=3, draw,
|
||||||
|
minimum size=8.5pt, inner sep=0pt, line width=0.7pt},
|
||||||
|
qfdrel/.is choice,
|
||||||
|
qfdrel/S/.style={qfdstrong},
|
||||||
|
qfdrel/M/.style={qfdmod},
|
||||||
|
qfdrel/W/.style={qfdweak},
|
||||||
|
qfdalt1mk/.style={circle, draw, fill=black,
|
||||||
|
minimum size=6pt, inner sep=0pt, line width=1pt},
|
||||||
|
qfdalt1ln/.style={line width=1.2pt},
|
||||||
|
qfdalt2mk/.style={regular polygon, regular polygon sides=3, draw,
|
||||||
|
fill=black, minimum size=6pt, inner sep=0pt,
|
||||||
|
line width=0.7pt},
|
||||||
|
qfdalt2ln/.style={line width=0.7pt, dashed},
|
||||||
|
qfdalt3mk/.style={rectangle, draw, fill=black,
|
||||||
|
minimum size=5pt, inner sep=0pt, line width=0.7pt},
|
||||||
|
qfdalt3ln/.style={line width=0.7pt, dotted},
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\qfdDrawGrid}{%
|
||||||
|
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
|
||||||
|
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
|
||||||
|
\foreach \r in {1,...,\qfdNWm}
|
||||||
|
\draw[qfdthin] (\qfdLeftEdge, -\r) -- (0, -\r);
|
||||||
|
\ifqfdshowroof
|
||||||
|
\foreach \c in {1,...,\qfdNHm}
|
||||||
|
\draw[qfdthin] (\c, 0) -- (\c, \qfdHdrH);
|
||||||
|
\fi
|
||||||
|
\ifqfdshowcompetitive
|
||||||
|
\foreach \r in {1,...,\qfdNWm}
|
||||||
|
\draw[qfdthin] (\qfdNH, -\r) -- (\qfdNH+\qfdCmpW, -\r);
|
||||||
|
\fi
|
||||||
|
\ifqfdshowbasement
|
||||||
|
\foreach \r in {1,...,\qfdBasementN}
|
||||||
|
\draw[qfdthin] (0, -\qfdNW-\r) -- (\qfdNH, -\qfdNW-\r);
|
||||||
|
\foreach \c in {1,...,\qfdNHm}
|
||||||
|
\draw[qfdthin] (\c, -\qfdNW) -- (\c, -\qfdNW-\qfdBasementN);
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\qfdDrawRoof}{%
|
||||||
|
\ifqfdshowroof
|
||||||
|
\foreach \k in {1,...,\qfdNHm} {%
|
||||||
|
\pgfmathsetmacro{\rx}{(\k+\qfdNH)/2}
|
||||||
|
\pgfmathsetmacro{\ry}{\qfdHdrH + (\qfdNH-\k)/2}
|
||||||
|
\pgfmathsetmacro{\lx}{\k/2}
|
||||||
|
\pgfmathsetmacro{\ly}{\qfdHdrH + \k/2}
|
||||||
|
\draw[qfdthin] (\k, \qfdHdrH) -- (\rx, \ry);
|
||||||
|
\draw[qfdthin] (\k, \qfdHdrH) -- (\lx, \ly);
|
||||||
|
}%
|
||||||
|
\draw[qfdmed] (0, \qfdHdrH)
|
||||||
|
-- (\qfdNH/2, \qfdApexY) -- (\qfdNH, \qfdHdrH);
|
||||||
|
\foreach \i in {1,...,\qfdNH}
|
||||||
|
\foreach \k in {1,...,\qfdNH} {%
|
||||||
|
\pgfmathtruncatemacro{\jj}{\i+\k}
|
||||||
|
\ifnum\jj>\qfdNH\relax\else
|
||||||
|
\pgfmathsetmacro{\xx}{\i + \k/2 - 0.5}
|
||||||
|
\pgfmathsetmacro{\yy}{\qfdHdrH + \k/2}
|
||||||
|
\coordinate (C-\i-\jj) at (\xx, \yy);
|
||||||
|
\fi
|
||||||
|
}%
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\qfdDrawScale}{%
|
||||||
|
\ifqfdshowcompetitive
|
||||||
|
\foreach \tk in {0,1,2,3,4,5} {%
|
||||||
|
\pgfmathsetmacro{\tx}{\qfdNH + (\tk+0.5)*\qfdCmpW/6}
|
||||||
|
\node[anchor=south, font=\scriptsize] at (\tx, 0.02) {\tk};
|
||||||
|
}%
|
||||||
|
\node[anchor=south, font=\scriptsize\bfseries, align=center]
|
||||||
|
at ({\qfdNH + \qfdCmpW/2}, 0.7) {\qfdPerceptionTitle};
|
||||||
|
\node[anchor=north, font=\scriptsize\itshape]
|
||||||
|
at ({\qfdNH + 0.45}, -\qfdNW) {\qfdPoorLabel};
|
||||||
|
\node[anchor=north, font=\scriptsize\itshape]
|
||||||
|
at ({\qfdNH + \qfdCmpW - 0.45}, -\qfdNW) {\qfdExcellentLabel};
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\qfdDrawZoneTitles}{%
|
||||||
|
\ifqfdshowimportance
|
||||||
|
\node[rotate=90, anchor=west, font=\footnotesize\bfseries]
|
||||||
|
at ({-\qfdImpW/2}, 0.12) {\qfdImpTitle};
|
||||||
|
\fi
|
||||||
|
\node[font=\scriptsize\bfseries, align=center, text width=\qfdWhatW cm]
|
||||||
|
at ({\qfdLeftEdge + \qfdWhatW/2},
|
||||||
|
{\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\qfdDrawTitle}{%
|
||||||
|
\ifqfdshowtitle
|
||||||
|
\ifx\qfdProjectTitle\empty\else
|
||||||
|
\pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
|
||||||
|
\pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
|
||||||
|
\pgfmathsetmacro{\qfdSubW}{\qfdNH + 2}
|
||||||
|
\node[anchor=south, font=\large\bfseries, align=center]
|
||||||
|
at (\qfdTitleX, \qfdTitleY) {\qfdProjectTitle};
|
||||||
|
\ifx\qfdConcept\empty\else
|
||||||
|
\node[anchor=north, font=\footnotesize\itshape, align=center,
|
||||||
|
text width=\qfdSubW cm]
|
||||||
|
at (\qfdTitleX, {\qfdTitleY - 0.1}) {\qfdConcept};
|
||||||
|
\fi
|
||||||
|
\fi
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\qfdDrawFrames}{%
|
||||||
|
\begin{scope}[qfdmed]
|
||||||
|
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
|
||||||
|
\ifqfdshowimportance \draw (-\qfdImpW, 0) -- (-\qfdImpW, -\qfdNW); \fi
|
||||||
|
\draw (0, 0) -- (0, -\qfdNW);
|
||||||
|
\ifqfdshowroof
|
||||||
|
\draw (0, 0) rectangle (\qfdNH, \qfdHdrH); \fi
|
||||||
|
\ifqfdshowbasement
|
||||||
|
\draw (0, -\qfdNW) rectangle (\qfdNH, -\qfdNW-\qfdBasementN); \fi
|
||||||
|
\ifqfdshowcompetitive
|
||||||
|
\draw (\qfdNH, 0) rectangle (\qfdNH+\qfdCmpW, -\qfdNW); \fi
|
||||||
|
\end{scope}
|
||||||
|
}
|
||||||
|
|
||||||
|
\newcommand{\qfdDrawLegend}{%
|
||||||
|
\ifqfdshowlegend
|
||||||
|
\pgfmathsetmacro{\qfdLegX}{%
|
||||||
|
\qfdNH + \ifqfdshowcompetitive \qfdCmpW + 0.7 \else 0.7 \fi}
|
||||||
|
\pgfmathsetmacro{\qfdLegBottom}{%
|
||||||
|
-2.05
|
||||||
|
\ifqfdshowroof \ifqfdshowcorrlegend - 2.55 \fi \fi
|
||||||
|
\ifqfdshowcompetitive \ifqfdshowevallegend - 2.20 \fi \fi}
|
||||||
|
\pgfmathsetmacro{\qfdLegY}{\qfdHdrH - 0.4}
|
||||||
|
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
|
||||||
|
\draw[qfdmed, rounded corners=2pt]
|
||||||
|
(-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
|
||||||
|
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1)
|
||||||
|
{\qfdRelTitle};
|
||||||
|
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
|
||||||
|
\node[qfdstrong] at (0.22, -0.5) {};
|
||||||
|
\node[anchor=west] at (0.5, -0.5) {Strong (9)};
|
||||||
|
\node[qfdmod] at (0.22, -0.95) {};
|
||||||
|
\node[anchor=west] at (0.5, -0.95) {Medium (3)};
|
||||||
|
\node[qfdweak] at (0.22, -1.4) {};
|
||||||
|
\node[anchor=west] at (0.5, -1.4) {Weak (1)};
|
||||||
|
\ifqfdshowroof \ifqfdshowcorrlegend
|
||||||
|
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10)
|
||||||
|
{\qfdCorrTitle};
|
||||||
|
\draw[qfdthin] (0, -2.35) -- (4.35, -2.35);
|
||||||
|
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
|
||||||
|
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
|
||||||
|
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
|
||||||
|
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
|
||||||
|
\fi \fi
|
||||||
|
\ifqfdshowcompetitive \ifqfdshowevallegend
|
||||||
|
\pgfmathsetmacro{\qfdEvalTop}{%
|
||||||
|
-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
|
||||||
|
\node[anchor=west, font=\footnotesize\bfseries]
|
||||||
|
at (0, \qfdEvalTop) {\qfdEvalTitle};
|
||||||
|
\pgfmathsetmacro{\qfdEvalSep}{\qfdEvalTop - 0.25}
|
||||||
|
\draw[qfdthin] (0, \qfdEvalSep) -- (4.35, \qfdEvalSep);
|
||||||
|
\pgfmathsetmacro{\qfdLegA}{\qfdEvalTop - 0.55}
|
||||||
|
\draw[qfdalt1ln] (0.05, \qfdLegA) -- (0.45, \qfdLegA);
|
||||||
|
\node[qfdalt1mk] at (0.25, \qfdLegA) {};
|
||||||
|
\node[anchor=west, font=\bfseries] at (0.55, \qfdLegA)
|
||||||
|
{\qfdAltOneLabel};
|
||||||
|
\pgfmathsetmacro{\qfdLegB}{\qfdEvalTop - 0.95}
|
||||||
|
\draw[qfdalt2ln] (0.05, \qfdLegB) -- (0.45, \qfdLegB);
|
||||||
|
\node[qfdalt2mk] at (0.25, \qfdLegB) {};
|
||||||
|
\node[anchor=west] at (0.55, \qfdLegB) {\qfdAltTwoLabel};
|
||||||
|
\pgfmathsetmacro{\qfdLegC}{\qfdEvalTop - 1.35}
|
||||||
|
\draw[qfdalt3ln] (0.05, \qfdLegC) -- (0.45, \qfdLegC);
|
||||||
|
\node[qfdalt3mk] at (0.25, \qfdLegC) {};
|
||||||
|
\node[anchor=west] at (0.55, \qfdLegC) {\qfdAltThreeLabel};
|
||||||
|
\fi \fi
|
||||||
|
\end{scope}
|
||||||
|
\fi
|
||||||
|
}
|
||||||
|
|
||||||
|
\newenvironment{qfdhouse}{%
|
||||||
|
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize,
|
||||||
|
line cap=round, line join=round]
|
||||||
|
\ifqfdshowimportance
|
||||||
|
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW-\qfdImpW}
|
||||||
|
\else
|
||||||
|
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW}
|
||||||
|
\fi
|
||||||
|
\pgfmathsetmacro{\qfdApexY}{\qfdHdrH + \qfdNH/2}
|
||||||
|
\pgfmathtruncatemacro{\qfdNHm}{\qfdNH - 1}
|
||||||
|
\pgfmathtruncatemacro{\qfdNWm}{\qfdNW - 1}
|
||||||
|
\qfdDrawGrid
|
||||||
|
\qfdDrawRoof
|
||||||
|
\qfdDrawScale
|
||||||
|
\qfdDrawZoneTitles
|
||||||
|
\qfdDrawTitle
|
||||||
|
}{%
|
||||||
|
\qfdDrawFrames
|
||||||
|
\qfdDrawLegend
|
||||||
|
\end{tikzpicture}%
|
||||||
|
}
|
||||||
|
|
||||||
|
% --- Product Radar overrides ---
|
||||||
|
\def\qfdNW{4}
|
||||||
|
\def\qfdNH{6}
|
||||||
|
\def\qfdWhatW{3.8}
|
||||||
|
\def\qfdHdrH{3.6}
|
||||||
|
\def\qfdWhatsTitle{Goals}
|
||||||
|
\def\qfdImpTitle{Imp.}
|
||||||
|
\qfdshowcompetitivefalse
|
||||||
|
|
||||||
|
\def\qfdProjectTitle{Product Radar}
|
||||||
|
\def\qfdConcept{Score up to 5 \textbf{Profiles} against shared
|
||||||
|
\textbf{Criteria} on a \textbf{radar chart} to position a product
|
||||||
|
at a glance, then share it as a self-contained link.}
|
||||||
|
|
||||||
|
\begin{document}
|
||||||
|
\begin{qfdhouse}
|
||||||
|
% --- WHATs (Goals) + importance weights (1-10) ---
|
||||||
|
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
|
||||||
|
\foreach \r/\t in {1/{Position at a glance}, 2/{Communicate it},
|
||||||
|
3/{Zero-setup persistence}, 4/{Low-friction editing}}
|
||||||
|
\node[anchor=west, font=\scriptsize, text width=\qfdWhatTextW cm, align=left]
|
||||||
|
at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
|
||||||
|
\foreach \r/\imp in {1/10, 2/8, 3/7, 4/6}
|
||||||
|
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
|
||||||
|
|
||||||
|
% --- HOWs (Functions, rotated) ---
|
||||||
|
\foreach \c/\t in {1/{Live chart redraw}, 2/{Distinct Profiles},
|
||||||
|
3/{Faithful PNG export}, 4/{Self-contained link},
|
||||||
|
5/{Browser-local library}, 6/{Valid, fluid editing}}
|
||||||
|
\node[rotate=90, anchor=west, font=\scriptsize] at ({\c - 0.5}, 0.15) {\t};
|
||||||
|
|
||||||
|
% --- Relations (Goals x Functions) ---
|
||||||
|
\node[qfdrel/S] at ({1 - 0.5}, {-1 + 0.5}) {};
|
||||||
|
\node[qfdrel/S] at ({2 - 0.5}, {-1 + 0.5}) {};
|
||||||
|
\node[qfdrel/M] at ({6 - 0.5}, {-1 + 0.5}) {};
|
||||||
|
\node[qfdrel/M] at ({2 - 0.5}, {-2 + 0.5}) {};
|
||||||
|
\node[qfdrel/S] at ({3 - 0.5}, {-2 + 0.5}) {};
|
||||||
|
\node[qfdrel/S] at ({4 - 0.5}, {-2 + 0.5}) {};
|
||||||
|
\node[qfdrel/W] at ({4 - 0.5}, {-3 + 0.5}) {};
|
||||||
|
\node[qfdrel/S] at ({5 - 0.5}, {-3 + 0.5}) {};
|
||||||
|
\node[qfdrel/S] at ({1 - 0.5}, {-4 + 0.5}) {};
|
||||||
|
\node[qfdrel/M] at ({5 - 0.5}, {-4 + 0.5}) {};
|
||||||
|
\node[qfdrel/S] at ({6 - 0.5}, {-4 + 0.5}) {};
|
||||||
|
|
||||||
|
% --- Roof correlations (Function x Function) ---
|
||||||
|
\node[font=\scriptsize] at (C-1-2) {$+$};
|
||||||
|
\node[font=\scriptsize] at (C-1-3) {$+\!+$};
|
||||||
|
\node[font=\scriptsize] at (C-1-5) {$-$};
|
||||||
|
\node[font=\scriptsize] at (C-1-6) {$+\!+$};
|
||||||
|
\node[font=\scriptsize] at (C-2-3) {$+$};
|
||||||
|
\node[font=\scriptsize] at (C-3-4) {$+$};
|
||||||
|
\node[font=\scriptsize] at (C-4-5) {$+$};
|
||||||
|
\node[font=\scriptsize] at (C-4-6) {$+$};
|
||||||
|
\node[font=\scriptsize] at (C-5-6) {$+$};
|
||||||
|
|
||||||
|
% --- Basement: target / difficulty / abs weight / rel weight % ---
|
||||||
|
\foreach \r/\lbl in {0/Target, 1/Difficulty, 2/Abs.\ wt, 3/Rel.\ wt \%}
|
||||||
|
\node[anchor=east, font=\scriptsize\itshape]
|
||||||
|
at ({-0.2}, {-\qfdNW - \r - 0.5}) {\lbl};
|
||||||
|
\foreach \c/\tgt/\diff/\abs/\rel in
|
||||||
|
{1/{$\leq$100ms}/2/144/25,
|
||||||
|
2/{5 distinct}/2/114/20,
|
||||||
|
3/{$\geq$2$\times$}/3/72/12,
|
||||||
|
4/{lossless}/3/79/14,
|
||||||
|
5/{0 network}/2/81/14,
|
||||||
|
6/{$\leq$1 click}/3/84/15} {
|
||||||
|
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
|
||||||
|
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\diff};
|
||||||
|
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\abs};
|
||||||
|
\node[font=\scriptsize\bfseries] at ({\c - 0.5}, {-\qfdNW - 3.5}) {\rel};
|
||||||
|
}
|
||||||
|
\end{qfdhouse}
|
||||||
|
\end{document}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals — the WHATs
|
||||||
|
|
||||||
|
| ID | Goal | Weight | Source |
|
||||||
|
|-----|-----------------------------------------------------------------------------------|:------:|-----------------------------------------------------|
|
||||||
|
| G1 | Position a product against alternatives across several dimensions, readable in one glance | 10 | `README.md`, `RadarChart.vue` |
|
||||||
|
| G2 | Communicate that positioning to others without them needing the app | 8 | `utils/png.ts`, `utils/share.ts`, `ShareImport.vue` |
|
||||||
|
| G3 | Keep and revisit my radars with zero setup — no account, no server | 7 | `storage.ts`, `RadarList.vue` |
|
||||||
|
| G4 | Build and adjust a radar with minimal friction | 6 | `CriteriaEditor.vue`, `ScoreGrid.vue`, `utils/reorder.ts` |
|
||||||
|
|
||||||
|
_Fairness of comparison (the "higher is better" discipline in `CONTEXT.md`) is treated
|
||||||
|
as a constraint on G1, not a separate goal._
|
||||||
|
|
||||||
|
## 2. Functions — the HOWs
|
||||||
|
|
||||||
|
Direction: ↑ higher is better, ↓ lower is better, → fixed capability / invariant.
|
||||||
|
|
||||||
|
| ID | Function | Dir | Target (now) | Target (future) |
|
||||||
|
|-----|---------------------------------------------------|:---:|--------------------------------------------------------------------------|-----------------|
|
||||||
|
| F1 | Redraw the chart live as the Radar changes | ↓ | ≤ 100 ms per edit (feels instant) | ≤ 50 ms |
|
||||||
|
| F2 | Keep up to 5 Profiles distinguishable on one chart| → | 5 Profiles × 7 Criteria, distinct palette colour + legend, no manual disambiguation | — |
|
||||||
|
| F3 | Export the chart as a faithful raster image | ↑ | PNG ≥ 2× resolution, white bg, includes name + Concept, 1 click | — |
|
||||||
|
| F4 | Encode an entire Radar into a self-contained link | → | Lossless round-trip, no server, untrusted import validated & clamped | — |
|
||||||
|
| F5 | Keep the whole Radar library in the browser | → | 0 network calls, survives reload/restart, auto-save; open/duplicate/delete each ≤ 1 action | — |
|
||||||
|
| F6 | Frictionless, always-valid editing | ↓ | Drag-reorder Criteria & Profiles; set a Score in ≤ 1 interaction; cardinality 3–7 / 1–5, Score 0–5, no nulls, unique names all unbreakable | — |
|
||||||
|
|
||||||
|
## 3. Cascade — Goals → Functions → How → Components
|
||||||
|
|
||||||
|
- **G1** Position a product at a glance _W:10_
|
||||||
|
- **F1** Live chart redraw _↓ ≤100 ms_
|
||||||
|
- **How**: Vue 3 reactivity drives a hand-built inline **SVG** chart that recomputes polygon points from Scores.
|
||||||
|
- **Component**: `RadarChart.vue`, Vue reactivity
|
||||||
|
- **How**: canvas charting lib (Chart.js) or imperative D3 — _rejected, see T1_
|
||||||
|
- **F2** Distinguishable Profiles _→_
|
||||||
|
- **How**: fixed 8-colour `PROFILE_PALETTE` (≥ MAX_PROFILES) + legend + semi-transparent fills.
|
||||||
|
- **Component**: `PROFILE_PALETTE` (`types.ts`), `RadarChart.vue` legend
|
||||||
|
- **G2** Communicate the positioning _W:8_
|
||||||
|
- **F3** Faithful raster export _↑_
|
||||||
|
- **How**: serialise the live SVG → draw into a 2× `<canvas>` → `toBlob` PNG; white bg; download or clipboard.
|
||||||
|
- **Component**: `utils/png.ts`
|
||||||
|
- **How**: server-side render / `html2canvas` / raw SVG download — _rejected, see T2_
|
||||||
|
- **F4** Self-contained share link _→_
|
||||||
|
- **How**: `base64url(JSON(payload))` in `/share?d=` query; history-mode route; import validates & clamps.
|
||||||
|
- **Component**: `utils/share.ts`, `RadarEditor.vue` (build link), `ShareImport.vue`, `useRadars.importRadar`, SPA fallback (`nginx try_files`, Pages `404.html`)
|
||||||
|
- **How**: server-stored short link, or compressed payload (LZ) — _rejected, see T3_
|
||||||
|
- **G3** Zero-setup persistence _W:7_
|
||||||
|
- **F5** Browser-local library _→_
|
||||||
|
- **How**: one `localStorage` key holds the `Radar[]`; deep `watch` auto-persists; CRUD via `useRadars`.
|
||||||
|
- **Component**: `storage.ts`
|
||||||
|
- **How**: IndexedDB, or backend sync — _rejected, see T4_
|
||||||
|
- **G4** Low-friction editing _W:6_
|
||||||
|
- **F6** Frictionless, always-valid editing _↓ / → invariant_
|
||||||
|
- **How**: pointer-based drag reorder + grid score entry; invariants enforced by `MIN/MAX` constants, `ensureScores`, and `importRadar` clamping.
|
||||||
|
- **Component**: `utils/reorder.ts`, `ScoreGrid.vue`, `CriteriaEditor.vue`, `ProfilesEditor.vue`, `storage.ensureScores`
|
||||||
|
|
||||||
|
## 4. House — Goals × Functions
|
||||||
|
|
||||||
|
Cells: link strength (9 strong / 3 medium / 1 weak / blank none). Importance row = `Σ(weight × strength)`.
|
||||||
|
|
||||||
|
| | F1 | F2 | F3 | F4 | F5 | F6 |
|
||||||
|
|------------|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||||
|
| G1 (10) | 9 | 9 | | | | 3 |
|
||||||
|
| G2 (8) | | 3 | 9 | 9 | | |
|
||||||
|
| G3 (7) | | | | 1 | 9 | |
|
||||||
|
| G4 (6) | 9 | | | | 3 | 9 |
|
||||||
|
| **Σ** |144 |114 | 72 | 79 | 81 | 84 |
|
||||||
|
| **Rel %** | 25 | 20 | 13 | 14 | 14 | 15 |
|
||||||
|
|
||||||
|
**Top engineering priorities (from importance):** the chart-rendering pair **F1 + F2 carries 45%** of the weight — they are the only functions that strongly serve the heaviest goal (G1 = 10), and F1 doubles into G4's editing feel. Sharing (F3 + F4 = 27%) is the most feature-visible part of the app but ranks below rendering: **getting the chart live and legible is where correctness matters most.** F3 (PNG), the headline README feature, ranks last by weight.
|
||||||
|
|
||||||
|
## 5. Roof — Function × Function tradeoffs
|
||||||
|
|
||||||
|
Symbols: `◎` strong reinforcement, `○` mild reinforcement, `×` mild conflict, `⊗` strong conflict.
|
||||||
|
|
||||||
|
| | F1 | F2 | F3 | F4 | F5 | F6 |
|
||||||
|
|--------|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||||
|
| **F1** | — | ○ | ◎ | | × | ◎ |
|
||||||
|
| **F2** | | — | ○ | | | |
|
||||||
|
| **F3** | | | — | ○ | | |
|
||||||
|
| **F4** | | | | — | ○ | ○ |
|
||||||
|
| **F5** | | | | | — | ○ |
|
||||||
|
| **F6** | | | | | | — |
|
||||||
|
|
||||||
|
**Conflicts that actually shape the design:**
|
||||||
|
|
||||||
|
- **F1 × F5 (auto-save vs live redraw).** Persistence deep-`watch`es the whole `radars` array and synchronously `JSON.stringify`s the entire store on every change, so each Score edit pays a full-store serialize on the main thread — competing with F1's ≤100 ms redraw budget. Harmless at current scale; degrades as the library grows. Owned by the §7 budget (F1 row) and the §8 watched tension; no ADR yet.
|
||||||
|
|
||||||
|
**Reinforcements that justify the architecture:**
|
||||||
|
|
||||||
|
- **F1 ◎ F3** — the hand-built SVG chosen for F1 is what makes PNG export faithful and dependency-free; the T1 bet pays off twice.
|
||||||
|
- **F1 ◎ F6** — live redraw is the feedback loop that makes editing feel frictionless.
|
||||||
|
- **F4 ○ F6** — F6's cardinality caps (≤7 Criteria, ≤5 Profiles) bound the uncompressed payload, protecting F4's long-URL weakness.
|
||||||
|
- **F4 ○ F5** — share-import reuses F5's `importRadar` validation/clamping; one code path.
|
||||||
|
|
||||||
|
## 6. Components & anchors
|
||||||
|
|
||||||
|
Function → component mapping is in the §3 cascade tree. Components whose decision is
|
||||||
|
anchored in an ADR:
|
||||||
|
|
||||||
|
| ID | Component | Realises | ADR |
|
||||||
|
|----|----------------------------------------|----------|----------|
|
||||||
|
| C1 | `RadarChart.vue` (hand-built SVG) | F1, F2 | — |
|
||||||
|
| C2 | `utils/png.ts` | F3 | — |
|
||||||
|
| C3 | `utils/share.ts` + `ShareImport.vue` | F4 | ADR-0001 |
|
||||||
|
| C4 | `storage.ts` (`useRadars`) | F5, F6 | — |
|
||||||
|
| C5 | `utils/reorder.ts`, editors, `ScoreGrid.vue` | F6 | — |
|
||||||
|
|
||||||
|
## 7. Critical performance budget
|
||||||
|
|
||||||
|
Ranked from §4 importance and the §5 conflict. Each row names the fallback if the target
|
||||||
|
is missed — a target without a fallback is a wish.
|
||||||
|
|
||||||
|
| Rank | Function | Target | Watched on | If we miss it |
|
||||||
|
|------|--------------|-------------------|-----------------------------------|----------------------------------------------------------------------------|
|
||||||
|
| 1 | F1 redraw | ≤ 100 ms per edit | devtools profile, 5×7 radar | Debounce / decouple the deep-`watch` persist from the keystroke path (also resolves F1×F5) |
|
||||||
|
| 2 | F2 distinct | 5 Profiles legible| visual review at 5 Profiles | Pattern fills in addition to colour + per-Profile show/hide toggle |
|
||||||
|
| 3 | F6 invariants| states unbreakable| hostile import + editor edge cases| Centralise validation in one module (today split editor ↔ `importRadar`) |
|
||||||
|
| 4 | F4 link size | fits URL limits | URL length at 7×5 + names/Concept | LZ-compress payload before base64; else fall back to a server short-link |
|
||||||
|
|
||||||
|
## 8. Tradeoffs — Got / Paid / ADR
|
||||||
|
|
||||||
|
| ID | Tradeoff | Got | Paid | ADR |
|
||||||
|
|----|-------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------|----------|
|
||||||
|
| T1 | Hand-built SVG over a charting library | Geometry control, exportable SVG (enables F3), tiny deps | Must hand-build axes / legend / scaling | — |
|
||||||
|
| T2 | Client SVG→canvas PNG over server / `html2canvas` | Zero infra, faithful (chart is already SVG), no heavy deps | Raster not vector; font-embedding & canvas-taint caveats | — |
|
||||||
|
| T3 | Self-contained link over server short-link / compression | Zero backend, links never expire, works on static hosting | Long URLs, full data in URL, no revoke / edit-after-share | ADR-0001 |
|
||||||
|
| T4 | localStorage over IndexedDB / backend | Trivially simple, synchronous, enough at small scale | ~5 MB quota; whole-store serialize per edit (F1×F5); single-origin | — |
|
||||||
|
|
||||||
|
### Tensions being watched (unresolved by design)
|
||||||
|
|
||||||
|
- **F1 × F5 store-serialize.** Not optimising while the library is tiny. **Trigger to revisit:** a stored library large enough that a Score edit's full-store serialize is perceptible, or the ≤100 ms budget is breached in profiling.
|
||||||
|
- **Validation split across editor and `importRadar` (F6).** Same rules live in two places; drift risk. **Trigger:** a third entry path appears, or the two diverge in a bug.
|
||||||
|
- **"Higher is better" is a hint, not enforced.** **Trigger:** users repeatedly create inverted Criteria (e.g. _Price_ instead of _Affordability_) and misread charts.
|
||||||
|
|
||||||
|
## 9. Inconsistencies spotted
|
||||||
|
|
||||||
|
- **README persistence claim.** `README.md` says persistence is "IndexedDB or localStorage"; `storage.ts` uses localStorage only (see T4). **Recommended resolution:** correct the README to say localStorage. _(Not changed in this design pass.)_
|
||||||
|
- **Two deploy pipelines.** Both Coolify/nginx and the GitHub Pages workflow (`.github/workflows/deploy-pages.yml`) deploy on push to `main`; the README documents only Coolify and the canonical domain (`product-radar.apoena.dev` vs the Pages custom domain) is unstated. **Recommended resolution:** state which target is canonical and whether Pages is a mirror. _(Not changed in this design pass.)_
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to keep this honest
|
||||||
|
|
||||||
|
- When a new ADR lands → add its components to §6 and re-score affected rows.
|
||||||
|
- When a spike / measurement returns numbers → update §7 `Target` / `Watched on`.
|
||||||
|
- WHATs change rarely; HOWs change with each release; matrices (§§4–6) are recomputed when either side changes.
|
||||||
|
- If a section becomes empty after edits, delete it — empty sections lie.
|
||||||
@@ -4,13 +4,13 @@ Compare up to 5 Profiles across 3–7 Criteria on a 0–5 scale, export the rada
|
|||||||
|
|
||||||
Deployed at https://product-radar.apoena.dev.
|
Deployed at https://product-radar.apoena.dev.
|
||||||
|
|
||||||
See [CONTEXT.md](./CONTEXT.md) for the domain glossary.
|
See [CONTEXT.md](./CONTEXT.md) for the domain glossary and [DESIGN.md](./DESIGN.md) for the goal-driven design (QFD). Architecture decisions live in [docs/adr/](./docs/adr/).
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- Vite + Vue 3 + TypeScript
|
- Vite + Vue 3 + TypeScript
|
||||||
- Tailwind CSS v4 + DaisyUI
|
- Tailwind CSS v4 + DaisyUI
|
||||||
- Solo, browser-local persistence (IndexedDB or localStorage)
|
- Solo, browser-local persistence via localStorage
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
@@ -22,4 +22,7 @@ pnpm build # production build into ./dist
|
|||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
Pushes to `main` are picked up by Coolify at https://platform.apoena.dev. The Dockerfile builds the SPA and serves it through nginx on port 80.
|
Every push to `main` triggers two independent deployments:
|
||||||
|
|
||||||
|
- **Coolify** (primary) — picked up at https://platform.apoena.dev and served at https://product-radar.apoena.dev. The Dockerfile builds the SPA and serves it through nginx on port 80, with `try_files … /index.html` as the history-mode SPA fallback.
|
||||||
|
- **GitHub Pages** — `.github/workflows/deploy-pages.yml` builds with pnpm and publishes `dist/`, copying `index.html` to `404.html` as the SPA fallback.
|
||||||
|
|||||||
25
docs/adr/0001-self-contained-share-links.md
Normal file
25
docs/adr/0001-self-contained-share-links.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Self-contained share links
|
||||||
|
|
||||||
|
Status: Accepted
|
||||||
|
|
||||||
|
Sharing a Radar must work with **no backend** — the app is a static SPA deployed to
|
||||||
|
nginx (Coolify) and GitHub Pages. We encode the entire Radar as `base64url(JSON(payload))`
|
||||||
|
in a `/share?d=…` query parameter rather than storing it server-side behind a short id;
|
||||||
|
the importing client validates and clamps the untrusted payload (`useRadars.importRadar`)
|
||||||
|
before it ever touches the store.
|
||||||
|
|
||||||
|
## Considered options
|
||||||
|
|
||||||
|
- **Self-contained link (chosen)** — full Radar in the URL.
|
||||||
|
- **Server-stored short link** — POST the Radar, share a short id. Rejected: requires a
|
||||||
|
backend + datastore, which the zero-infra constraint forbids.
|
||||||
|
- **Compressed payload (LZ-string)** — shorter URLs, same self-contained property.
|
||||||
|
Deferred, not rejected: the §7 fallback if link length becomes a problem.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Got:** zero infrastructure; links never expire; works on any static host; offline-capable.
|
||||||
|
- **Paid:** long URLs (no compression today); the full Radar is exposed in the URL (no
|
||||||
|
privacy boundary); no revoke and no edit-after-share — a shared link is a frozen copy.
|
||||||
|
- Mitigated by the cardinality caps (≤7 Criteria, ≤5 Profiles) which bound payload size,
|
||||||
|
keeping URLs within browser limits at current scale.
|
||||||
@@ -1,11 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
import type { Radar } from "../types"
|
import type { Radar } from "../types"
|
||||||
import { MIN_CRITERIA, MAX_CRITERIA } from "../types"
|
import { MIN_CRITERIA, MAX_CRITERIA } from "../types"
|
||||||
import { useRadars } from "../storage"
|
import { useRadars } from "../storage"
|
||||||
|
import { useDragReorder } from "../utils/reorder"
|
||||||
|
|
||||||
const props = defineProps<{ radar: Radar }>()
|
const props = defineProps<{ radar: Radar }>()
|
||||||
const { update, blankCriterion } = useRadars()
|
const { update, blankCriterion } = useRadars()
|
||||||
|
|
||||||
|
const listEl = ref<HTMLElement | null>(null)
|
||||||
|
const { draggingId, startDrag } = useDragReorder({
|
||||||
|
container: listEl,
|
||||||
|
getIds: () => props.radar.criteria.map((c) => c.id),
|
||||||
|
reorder: (from, to) =>
|
||||||
|
update(props.radar.id, (r) => {
|
||||||
|
const [moved] = r.criteria.splice(from, 1)
|
||||||
|
r.criteria.splice(to, 0, moved)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
function addCriterion(): void {
|
function addCriterion(): void {
|
||||||
update(props.radar.id, (r) => {
|
update(props.radar.id, (r) => {
|
||||||
if (r.criteria.length >= MAX_CRITERIA) return
|
if (r.criteria.length >= MAX_CRITERIA) return
|
||||||
@@ -39,8 +52,22 @@ function renameCriterion(criterionId: string, name: string): void {
|
|||||||
<p class="text-xs text-base-content/60 mb-3">
|
<p class="text-xs text-base-content/60 mb-3">
|
||||||
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not <em>Price</em>).
|
Phrase each Criterion so higher is better (e.g. <em>Affordability</em>, not <em>Price</em>).
|
||||||
</p>
|
</p>
|
||||||
<ul class="flex flex-col gap-2">
|
<ul ref="listEl" class="flex flex-col gap-2">
|
||||||
<li v-for="c in radar.criteria" :key="c.id" class="flex items-center gap-2">
|
<li
|
||||||
|
v-for="c in radar.criteria"
|
||||||
|
:key="c.id"
|
||||||
|
data-reorder-item
|
||||||
|
class="flex items-center gap-2 rounded-lg transition-opacity"
|
||||||
|
:class="draggingId === c.id ? 'opacity-50' : ''"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="cursor-grab touch-none select-none px-1 text-base-content/40 hover:text-base-content/70 active:cursor-grabbing"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
title="Drag to reorder"
|
||||||
|
@pointerdown="startDrag($event, c.id)"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="input input-bordered input-sm flex-1"
|
class="input input-bordered input-sm flex-1"
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ref } from "vue"
|
||||||
import type { Radar } from "../types"
|
import type { Radar } from "../types"
|
||||||
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types"
|
import { MIN_PROFILES, MAX_PROFILES, PROFILE_PALETTE } from "../types"
|
||||||
import { useRadars } from "../storage"
|
import { useRadars } from "../storage"
|
||||||
|
import { useDragReorder } from "../utils/reorder"
|
||||||
|
|
||||||
const props = defineProps<{ radar: Radar }>()
|
const props = defineProps<{ radar: Radar }>()
|
||||||
const { update, blankProfile } = useRadars()
|
const { update, blankProfile } = useRadars()
|
||||||
|
|
||||||
|
const listEl = ref<HTMLElement | null>(null)
|
||||||
|
const { draggingId, startDrag } = useDragReorder({
|
||||||
|
container: listEl,
|
||||||
|
getIds: () => props.radar.profiles.map((p) => p.id),
|
||||||
|
reorder: (from, to) =>
|
||||||
|
update(props.radar.id, (r) => {
|
||||||
|
const [moved] = r.profiles.splice(from, 1)
|
||||||
|
r.profiles.splice(to, 0, moved)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
function addProfile(): void {
|
function addProfile(): void {
|
||||||
update(props.radar.id, (r) => {
|
update(props.radar.id, (r) => {
|
||||||
if (r.profiles.length >= MAX_PROFILES) return
|
if (r.profiles.length >= MAX_PROFILES) return
|
||||||
@@ -47,13 +60,23 @@ function setColor(profileId: string, color: string): void {
|
|||||||
Each Profile is one shape on the radar (e.g. <em>Us today</em>, <em>Us 2027</em>,
|
Each Profile is one shape on the radar (e.g. <em>Us today</em>, <em>Us 2027</em>,
|
||||||
<em>Acme Corp</em>).
|
<em>Acme Corp</em>).
|
||||||
</p>
|
</p>
|
||||||
<ul class="flex flex-col gap-3">
|
<ul ref="listEl" class="flex flex-col gap-3">
|
||||||
<li
|
<li
|
||||||
v-for="p in radar.profiles"
|
v-for="p in radar.profiles"
|
||||||
:key="p.id"
|
:key="p.id"
|
||||||
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 bg-base-100"
|
data-reorder-item
|
||||||
|
class="flex flex-col gap-2 p-3 rounded-lg border border-base-300 bg-base-100 transition-opacity"
|
||||||
|
:class="draggingId === p.id ? 'opacity-50' : ''"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="cursor-grab touch-none select-none text-base-content/40 hover:text-base-content/70 active:cursor-grabbing"
|
||||||
|
aria-label="Drag to reorder"
|
||||||
|
title="Drag to reorder"
|
||||||
|
@pointerdown="startDrag($event, p.id)"
|
||||||
|
>
|
||||||
|
⠿
|
||||||
|
</span>
|
||||||
<span
|
<span
|
||||||
class="inline-block w-4 h-4 rounded"
|
class="inline-block w-4 h-4 rounded"
|
||||||
:style="{ background: p.color }"
|
:style="{ background: p.color }"
|
||||||
|
|||||||
62
src/utils/reorder.ts
Normal file
62
src/utils/reorder.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { onScopeDispose, ref, type Ref } from "vue"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pointer-based drag-to-reorder for a vertical list. Works with mouse and touch
|
||||||
|
* (via Pointer Events). The list must render its items in array order, each item
|
||||||
|
* element carrying a `data-reorder-item` attribute, inside `container`.
|
||||||
|
*
|
||||||
|
* Reordering is live: as the pointer crosses a neighbour's midpoint, `reorder`
|
||||||
|
* is called to move the dragged item, and the list re-renders in the new order.
|
||||||
|
*/
|
||||||
|
export function useDragReorder(opts: {
|
||||||
|
container: Ref<HTMLElement | null>
|
||||||
|
getIds: () => string[]
|
||||||
|
reorder: (from: number, to: number) => void
|
||||||
|
}) {
|
||||||
|
const draggingId = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Insertion index (in current DOM order) for the given pointer Y.
|
||||||
|
function indexFromPoint(clientY: number): number {
|
||||||
|
const el = opts.container.value
|
||||||
|
if (!el) return -1
|
||||||
|
const items = Array.from(el.querySelectorAll<HTMLElement>("[data-reorder-item]"))
|
||||||
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
const r = items[i].getBoundingClientRect()
|
||||||
|
if (clientY < r.top + r.height / 2) return i
|
||||||
|
}
|
||||||
|
return items.length
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e: PointerEvent): void {
|
||||||
|
const id = draggingId.value
|
||||||
|
if (id == null) return
|
||||||
|
const ids = opts.getIds()
|
||||||
|
const from = ids.indexOf(id)
|
||||||
|
if (from === -1) return
|
||||||
|
let to = indexFromPoint(e.clientY)
|
||||||
|
if (to === -1) return
|
||||||
|
// The dragged item still occupies its slot, so an insertion point below it
|
||||||
|
// shifts down by one once it's removed.
|
||||||
|
if (from < to) to--
|
||||||
|
if (to !== from) opts.reorder(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(): void {
|
||||||
|
draggingId.value = null
|
||||||
|
window.removeEventListener("pointermove", onMove)
|
||||||
|
window.removeEventListener("pointerup", stop)
|
||||||
|
window.removeEventListener("pointercancel", stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDrag(e: PointerEvent, id: string): void {
|
||||||
|
e.preventDefault()
|
||||||
|
draggingId.value = id
|
||||||
|
window.addEventListener("pointermove", onMove)
|
||||||
|
window.addEventListener("pointerup", stop)
|
||||||
|
window.addEventListener("pointercancel", stop)
|
||||||
|
}
|
||||||
|
|
||||||
|
onScopeDispose(stop)
|
||||||
|
|
||||||
|
return { draggingId, startDrag }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user