diff --git a/src/app/page.tsx b/src/app/page.tsx index 34077e7..7c85917 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ -import ProfileFormScene from "~/features/components/_provider/ProfileFormScene"; +import VennDiagramScene from "~/features/components/venn/VennDiagramScene"; -const PageProfileForm = () => ; +const PageTop = () => ; -export default PageProfileForm; +export default PageTop; diff --git a/src/features/components/venn/VennDiagramScene.tsx b/src/features/components/venn/VennDiagramScene.tsx new file mode 100644 index 0000000..7986f1f --- /dev/null +++ b/src/features/components/venn/VennDiagramScene.tsx @@ -0,0 +1,565 @@ +"use client"; + +import styled from "@emotion/styled"; +import { useState, useRef, useCallback } from "react"; +import useVennDiagramStorage from "~/features/lib/useVennDiagramStorage"; +import { type VennRegion } from "~/features/schema/VennDiagram"; +import VennKeywordChip from "~/features/components/venn/VennKeywordChip"; +import { responsiveUnit } from "~/features/lib/emotion-mixin"; + +// Layout constants (vw-based for mobile responsiveness) +// Two circles of radius 44vw, centered at x=50vw +// Circle A center: y=47vw, Circle B center: y=97vw +// Overlap: y=53vw to y=91vw (overlap depth = 38vw) +const R = 44; // vw +const CY_A = 47; // vw +const CY_B = 97; // vw +const OVERLAP_TOP = CY_B - R; // 53vw +const OVERLAP_BOT = CY_A + R; // 91vw +const TOTAL_H = CY_B + R; // 141vw + +const Wrapper = styled.div({ + minHeight: "100dvh", + background: "linear-gradient(160deg, #f0f4ff 0%, #fdf0f8 100%)", + display: "flex", + flexDirection: "column", + alignItems: "center", + padding: "16px 0 40px" +}); + +const Title = styled.h1( + { + fontWeight: 700, + color: "#444", + letterSpacing: 0.5 + }, + responsiveUnit(u => ({ + fontSize: u(18), + marginBottom: u(20) + })) +); + +const DiagramSection = styled.div( + { + width: "100%" + }, + responsiveUnit(u => ({ + maxWidth: u(480) + })) +); + +const DiagramContainer = styled.div({ + position: "relative" +}); + +const SvgBg = styled.svg({ + position: "absolute", + top: 0, + left: 0, + width: "100%", + height: "100%", + pointerEvents: "none" +}); + +const RegionArea = styled.div<{ region: VennRegion; isOver: boolean }>( + ({ isOver }) => ({ + position: "absolute", + left: 0, + right: 0, + display: "flex", + flexWrap: "wrap", + gap: 6, + padding: "10px 12px", + alignItems: "center", + justifyContent: "center", + cursor: "pointer", + borderRadius: 8, + transition: "background 0.2s", + background: isOver ? "rgba(255,255,255,0.35)" : "transparent" + }) +); + +const AddHint = styled.span({ + fontSize: 12, + color: "rgba(100,100,140,0.6)", + userSelect: "none" +}); + +const GroupNameRow = styled.div( + { + width: "100%", + display: "flex" + }, + responsiveUnit(u => ({ + maxWidth: u(480), + gap: u(12), + marginBottom: u(12), + padding: u(0, 16) + })) +); + +const GroupNameInput = styled.input<{ accent: string }>( + ({ accent }) => ({ + flex: 1, + border: `0 solid ${accent}`, + fontWeight: 700, + color: "#333", + background: "rgba(255,255,255,0.8)", + outline: "none", + textAlign: "center" + }), + responsiveUnit(u => ({ + borderWidth: u(2), + borderRadius: u(8), + padding: u(6, 10), + fontSize: u(14) + })) +); + +const GroupLabel = styled.div<{ color: string }>( + ({ color }) => ({ + fontWeight: 700, + color, + letterSpacing: 0.5, + pointerEvents: "none", + userSelect: "none" + }), + responsiveUnit(u => ({ + fontSize: u(13) + })) +); + +// Modal +const ModalOverlay = styled.div({ + position: "fixed", + inset: 0, + background: "rgba(0,0,0,0.35)", + display: "flex", + alignItems: "flex-end", + justifyContent: "center", + zIndex: 100 +}); + +const ModalSheet = styled.div({ + background: "#fff", + borderRadius: "20px 20px 0 0", + padding: "24px 20px 40px", + width: "100%", + maxWidth: 480 +}); + +const ModalTitle = styled.p({ + fontSize: 14, + color: "#888", + marginBottom: 12, + textAlign: "center" +}); + +const ModalInput = styled.input({ + width: "100%", + border: "2px solid #ccc", + borderRadius: 10, + padding: "10px 14px", + fontSize: 16, + outline: "none", + marginBottom: 12, + boxSizing: "border-box", + "&:focus": { borderColor: "#8880ff" } +}); + +const ModalButton = styled.button({ + width: "100%", + padding: "12px", + borderRadius: 10, + border: "none", + background: "linear-gradient(90deg,#7b8fff,#c07bff)", + color: "#fff", + fontSize: 16, + fontWeight: 700, + cursor: "pointer" +}); + +const DragGhost = styled.div({ + position: "fixed", + pointerEvents: "none", + zIndex: 200, + padding: "4px 10px", + borderRadius: 20, + background: "rgba(120,100,255,0.9)", + color: "#fff", + fontSize: 13, + fontWeight: 600, + boxShadow: "0 4px 20px rgba(0,0,0,0.3)", + transform: "translate(-50%,-50%)" +}); + +type DragState = { + id: string; + text: string; + x: number; + y: number; +}; + +const VennDiagramScene = () => { + const { + data, + setGroupAName, + setGroupBName, + addKeyword, + moveKeyword, + deleteKeyword + } = useVennDiagramStorage(); + + const [addingRegion, setAddingRegion] = useState(null); + const [inputText, setInputText] = useState(""); + const [dragState, setDragState] = useState(null); + const [overRegion, setOverRegion] = useState(null); + + const containerRef = useRef(null); + const regionRefs = useRef>>({}); + + // Convert clientY to vw units relative to container + const getRegionFromPoint = useCallback( + (x: number, y: number): VennRegion | null => { + const el = document.elementFromPoint(x, y); + if (!el) { + return null; + } + const region = (el as HTMLElement).dataset?.region as + | VennRegion + | undefined; + if (region) { + return region; + } + // Walk up to find data-region + const parent = (el as HTMLElement).closest("[data-region]"); + if (parent) { + return (parent as HTMLElement).dataset.region as VennRegion; + } + return null; + }, + [] + ); + + const handleDragStart = useCallback( + (id: string) => { + const kw = data.keywords.find(k => k.id === id); + if (!kw) { + return; + } + setDragState({ id, text: kw.text, x: 0, y: 0 }); + }, + [data.keywords] + ); + + const handleTouchMove = useCallback( + (e: React.TouchEvent) => { + if (!dragState) { + return; + } + const t = e.touches[0]; + setDragState(s => (s ? { ...s, x: t.clientX, y: t.clientY } : null)); + const region = getRegionFromPoint(t.clientX, t.clientY); + setOverRegion(region); + }, + [dragState, getRegionFromPoint] + ); + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + if (!dragState) { + return; + } + setDragState(s => (s ? { ...s, x: e.clientX, y: e.clientY } : null)); + const region = getRegionFromPoint(e.clientX, e.clientY); + setOverRegion(region); + }, + [dragState, getRegionFromPoint] + ); + + const handleDragEnd = useCallback( + (id: string, x: number, y: number) => { + const region = getRegionFromPoint(x, y); + if (region) { + moveKeyword(id, region); + } + setDragState(null); + setOverRegion(null); + }, + [getRegionFromPoint, moveKeyword] + ); + + const handleRegionClick = useCallback( + (region: VennRegion) => { + if (dragState) { + return; + } + setAddingRegion(region); + setInputText(""); + }, + [dragState] + ); + + const handleAddSubmit = useCallback(() => { + if (!inputText.trim() || !addingRegion) { + return; + } + addKeyword(inputText.trim(), addingRegion); + setAddingRegion(null); + setInputText(""); + }, [inputText, addingRegion, addKeyword]); + + const keywordsByRegion = useCallback( + (r: VennRegion) => data.keywords.filter(k => k.region === r), + [data.keywords] + ); + + // vw to % string helper (same value, just for SVG viewBox) + const vbH = TOTAL_H; + + return ( + { + setDragState(null); + setOverRegion(null); + }} + > + ベン図アイデア整理 + + + setGroupAName(e.target.value)} + placeholder="グループ A" + /> + setGroupBName(e.target.value)} + placeholder="グループ B" + /> + + + + + {/* SVG background circles */} + + + + + + + + + + {/* Circle A fill */} + + {/* Circle B fill */} + + {/* Intersection highlight */} + + + + {/* Group A label */} +
+ + {data.groupAName} + +
+ + {/* Group B label */} +
+ + {data.groupBName} + +
+ + {/* Region A-only */} + { + if (el) { + regionRefs.current.a = el; + } + }} + style={{ + top: `${((CY_A - R + 14) / vbH) * 100}%`, + height: `${((OVERLAP_TOP - (CY_A - R + 14)) / vbH) * 100}%` + }} + onClick={() => handleRegionClick("a")} + > + {keywordsByRegion("a").map(kw => ( + + ))} + {keywordsByRegion("a").length === 0 && ( + タップしてキーワードを追加 + )} + + + {/* Region Intersection */} + { + if (el) { + regionRefs.current.both = el; + } + }} + style={{ + top: `${(OVERLAP_TOP / vbH) * 100}%`, + height: `${((OVERLAP_BOT - OVERLAP_TOP) / vbH) * 100}%` + }} + onClick={() => handleRegionClick("both")} + > + {keywordsByRegion("both").map(kw => ( + + ))} + {keywordsByRegion("both").length === 0 && ( + 共通領域 + )} + + + {/* Region B-only */} + { + if (el) { + regionRefs.current.b = el; + } + }} + style={{ + top: `${(OVERLAP_BOT / vbH) * 100}%`, + height: `${((CY_B + R - 14 - OVERLAP_BOT) / vbH) * 100}%` + }} + onClick={() => handleRegionClick("b")} + > + {keywordsByRegion("b").map(kw => ( + + ))} + {keywordsByRegion("b").length === 0 && ( + タップしてキーワードを追加 + )} + +
+
+ + {/* Drag ghost */} + {dragState && dragState.x !== 0 && ( + + {dragState.text} + + )} + + {/* Add keyword modal */} + {addingRegion && ( + setAddingRegion(null)}> + e.stopPropagation()}> + + 「 + {addingRegion === "a" + ? data.groupAName + : addingRegion === "b" + ? data.groupBName + : "共通"} + 」にキーワードを追加 + +
{ + e.preventDefault(); + handleAddSubmit(); + }} + > + setInputText(e.target.value)} + placeholder="キーワードを入力..." + onKeyDown={e => { + if (e.key === "Escape") { + setAddingRegion(null); + } + }} + /> + 追加する + +
+
+ )} +
+ ); +}; + +export default VennDiagramScene; diff --git a/src/features/components/venn/VennKeywordChip.tsx b/src/features/components/venn/VennKeywordChip.tsx new file mode 100644 index 0000000..1a82681 --- /dev/null +++ b/src/features/components/venn/VennKeywordChip.tsx @@ -0,0 +1,121 @@ +"use client"; + +import styled from "@emotion/styled"; +import { useRef, useCallback } from "react"; +import { responsiveUnit } from "~/features/lib/emotion-mixin"; + +type Props = { + id: string; + text: string; + onDragStart: (id: string) => void; + onDragEnd: (id: string, x: number, y: number) => void; + onDelete: (id: string) => void; +}; + +const Chip = styled.div( + { + display: "inline-flex", + alignItems: "center", + background: "rgba(255,255,255,0.85)", + border: "0 solid rgba(80,80,120,0.25)", + fontWeight: 600, + color: "#333", + cursor: "grab", + userSelect: "none", + touchAction: "none", + transition: "box-shadow 0.15s", + "&:active": { + cursor: "grabbing" + } + }, + responsiveUnit(u => ({ + gap: u(4), + padding: u(4, 10), + borderRadius: u(20), + fontSize: u(13), + borderWidth: u(1.5), + boxShadow: `${u(0, 1, 4)} rgba(0,0,0,0.1)`, + "&:active": { + boxShadow: `${u(0, 4, 16)} rgba(0,0,0,0.2)` + } + })) +); + +const DeleteBtn = styled.button( + { + appearance: "none", + border: "none", + background: "none", + padding: 0, + margin: 0, + lineHeight: 1, + cursor: "pointer", + color: "#aaa", + display: "flex", + alignItems: "center" + }, + responsiveUnit(u => ({ + fontSize: u(14) + })) +); + +const VennKeywordChip = ({ + id, + text, + onDragStart, + onDragEnd, + onDelete +}: Props) => { + const touchRef = useRef<{ startX: number; startY: number } | null>(null); + + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + const t = e.touches[0]; + touchRef.current = { startX: t.clientX, startY: t.clientY }; + onDragStart(id); + }, + [id, onDragStart] + ); + + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + const t = e.changedTouches[0]; + onDragEnd(id, t.clientX, t.clientY); + touchRef.current = null; + }, + [id, onDragEnd] + ); + + const handleMouseDown = useCallback(() => { + onDragStart(id); + }, [id, onDragStart]); + + const handleMouseUp = useCallback( + (e: React.MouseEvent) => { + onDragEnd(id, e.clientX, e.clientY); + }, + [id, onDragEnd] + ); + + return ( + + {text} + { + e.stopPropagation(); + onDelete(id); + }} + aria-label="削除" + > + × + + + ); +}; + +export default VennKeywordChip; diff --git a/src/features/lib/emotion-mixin.ts b/src/features/lib/emotion-mixin.ts index ca77b50..c2d3fab 100644 --- a/src/features/lib/emotion-mixin.ts +++ b/src/features/lib/emotion-mixin.ts @@ -8,10 +8,8 @@ import { } from "~/common/lib/css-util"; // NOTE: デザインファイルのサイズに合わせる -const SP_VIEWPORT_SIZE = 1125; -const PC_VIEWPORT_SIZE = 2880; - -const BREAKPOINT_MIN_PC = 800; +const SP_VIEWPORT_SIZE = 520; +const BREAKPOINT_MIN_PC = SP_VIEWPORT_SIZE; export const globalStyle = css({ body: { @@ -32,16 +30,17 @@ export const pcStyle = (styles: CSSInterpolation) => [`@media(min-width: ${px(BREAKPOINT_MIN_PC)})`]: styles }); -export const pcp = (...nums: CSSSizeKeyword[]) => - vw( - ...nums.map(n => (typeof n === "string" ? n : (n / PC_VIEWPORT_SIZE) * 100)) - ); +export const pcp = (...nums: CSSSizeKeyword[]) => px(...nums); export const spp = (...nums: CSSSizeKeyword[]) => vw( ...nums.map(n => (typeof n === "string" ? n : (n / SP_VIEWPORT_SIZE) * 100)) ); +export const responsiveUnit = ( + fn: (u: (...nums: CSSSizeKeyword[]) => string) => CSSInterpolation +) => [pcStyle(fn(pcp)), spStyle(fn(spp))]; + export const THEME_COLOR = { ...PRIMITIVE_COLOR } as const; diff --git a/src/features/lib/useVennDiagramStorage.ts b/src/features/lib/useVennDiagramStorage.ts new file mode 100644 index 0000000..eb16c62 --- /dev/null +++ b/src/features/lib/useVennDiagramStorage.ts @@ -0,0 +1,95 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { + type VennDiagramData, + type VennKeyword, + type VennRegion +} from "~/features/schema/VennDiagram"; +import { defaultVennDiagramData } from "~/features/schema/VennDiagram"; + +const STORAGE_KEY = "venn-diagram-data"; + +const loadFromStorage = (): VennDiagramData => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) { + return defaultVennDiagramData(); + } + return JSON.parse(raw) as VennDiagramData; + } catch { + return defaultVennDiagramData(); + } +}; + +const saveToStorage = (data: VennDiagramData) => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch { + // ignore + } +}; + +const useVennDiagramStorage = () => { + const [data, setData] = useState(defaultVennDiagramData); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + setData(loadFromStorage()); + }, []); + + const update = useCallback((next: VennDiagramData) => { + setData(next); + saveToStorage(next); + }, []); + + const setGroupAName = useCallback( + (name: string) => update({ ...data, groupAName: name }), + [data, update] + ); + + const setGroupBName = useCallback( + (name: string) => update({ ...data, groupBName: name }), + [data, update] + ); + + const addKeyword = useCallback( + (text: string, region: VennRegion) => { + const keyword: VennKeyword = { + id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, + text, + region + }; + update({ ...data, keywords: [...data.keywords, keyword] }); + }, + [data, update] + ); + + const moveKeyword = useCallback( + (id: string, region: VennRegion) => { + update({ + ...data, + keywords: data.keywords.map(k => (k.id === id ? { ...k, region } : k)) + }); + }, + [data, update] + ); + + const deleteKeyword = useCallback( + (id: string) => { + update({ ...data, keywords: data.keywords.filter(k => k.id !== id) }); + }, + [data, update] + ); + + return { + data, + setGroupAName, + setGroupBName, + addKeyword, + moveKeyword, + deleteKeyword + }; +}; + +export default useVennDiagramStorage; diff --git a/src/features/schema/VennDiagram.ts b/src/features/schema/VennDiagram.ts new file mode 100644 index 0000000..f8cb580 --- /dev/null +++ b/src/features/schema/VennDiagram.ts @@ -0,0 +1,25 @@ +export type VennRegion = "a" | "both" | "b"; + +export type VennKeyword = { + id: string; + text: string; + region: VennRegion; +}; + +export type VennDiagramData = { + groupAName: string; + groupBName: string; + keywords: VennKeyword[]; +}; + +export const VENN_REGION_LABELS: Record = { + a: "グループA のみ", + both: "共通", + b: "グループB のみ" +}; + +export const defaultVennDiagramData = (): VennDiagramData => ({ + groupAName: "グループ A", + groupBName: "グループ B", + keywords: [] +});