diff --git a/examples/wardley-map/app/components/WardleyCanvas.tsx b/examples/wardley-map/app/components/WardleyCanvas.tsx new file mode 100644 index 0000000..3f786de --- /dev/null +++ b/examples/wardley-map/app/components/WardleyCanvas.tsx @@ -0,0 +1,647 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; +import rough from "roughjs"; +import type { RoughCanvas } from "roughjs/bin/canvas"; +import type { + WardleyMapData, + WardleyComponent, + WardleyConnection, + CanvasState, + Tool, +} from "@/lib/types"; +import { EVOLUTION_STAGES } from "@/lib/types"; +import { v4 as uuidv4 } from "uuid"; + +interface WardleyCanvasProps { + data: WardleyMapData; + onChange: (data: WardleyMapData) => void; +} + +const PADDING = 60; +const COMPONENT_RADIUS = 6; + +export function WardleyCanvas({ data, onChange }: WardleyCanvasProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const roughCanvasRef = useRef(null); + + const [canvasState, setCanvasState] = useState({ + zoom: 1, + panX: 0, + panY: 0, + selectedId: null, + tool: "select", + }); + + const [isDragging, setIsDragging] = useState(false); + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); + const [connectionStart, setConnectionStart] = useState(null); + const [editingComponent, setEditingComponent] = useState(null); + const [editingName, setEditingName] = useState(""); + + // Convert map coordinates to canvas coordinates + const mapToCanvas = useCallback( + (evolution: number, visibility: number, width: number, height: number) => { + const drawWidth = width - PADDING * 2; + const drawHeight = height - PADDING * 2; + return { + x: PADDING + evolution * drawWidth + canvasState.panX, + y: PADDING + (1 - visibility) * drawHeight + canvasState.panY, + }; + }, + [canvasState.panX, canvasState.panY] + ); + + // Convert canvas coordinates to map coordinates + const canvasToMap = useCallback( + (x: number, y: number, width: number, height: number) => { + const drawWidth = width - PADDING * 2; + const drawHeight = height - PADDING * 2; + const evolution = Math.max(0, Math.min(1, (x - PADDING - canvasState.panX) / drawWidth)); + const visibility = Math.max(0, Math.min(1, 1 - (y - PADDING - canvasState.panY) / drawHeight)); + return { evolution, visibility }; + }, + [canvasState.panX, canvasState.panY] + ); + + // Find component at position + const findComponentAt = useCallback( + (x: number, y: number, width: number, height: number): WardleyComponent | null => { + for (const component of data.components) { + const pos = mapToCanvas(component.evolution, component.visibility, width, height); + const dx = x - pos.x; + const dy = y - pos.y; + if (Math.sqrt(dx * dx + dy * dy) < COMPONENT_RADIUS + 10) { + return component; + } + } + return null; + }, + [data.components, mapToCanvas] + ); + + // Draw the canvas + const draw = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const rc = rough.canvas(canvas); + roughCanvasRef.current = rc; + + const width = canvas.width; + const height = canvas.height; + const drawWidth = width - PADDING * 2; + const drawHeight = height - PADDING * 2; + + // Clear canvas + ctx.fillStyle = "#faf9f6"; + ctx.fillRect(0, 0, width, height); + + // Draw rough paper texture background + ctx.save(); + ctx.globalAlpha = 0.03; + for (let i = 0; i < 100; i++) { + const x = Math.random() * width; + const y = Math.random() * height; + ctx.fillStyle = "#000"; + ctx.fillRect(x, y, 1, 1); + } + ctx.restore(); + + // Draw sketch-style border + rc.rectangle(PADDING - 5, PADDING - 5, drawWidth + 10, drawHeight + 10, { + roughness: 1.5, + stroke: "#666", + strokeWidth: 1.5, + bowing: 2, + }); + + // Draw evolution axis labels + ctx.font = "14px Caveat, cursive, sans-serif"; + ctx.fillStyle = "#444"; + ctx.textAlign = "center"; + + EVOLUTION_STAGES.forEach((stage) => { + const x = PADDING + stage.position * drawWidth + canvasState.panX; + + // Draw tick mark + rc.line(x, PADDING + drawHeight + canvasState.panY, x, PADDING + drawHeight + 10 + canvasState.panY, { + roughness: 1, + stroke: "#888", + }); + + ctx.fillText(stage.label, x, PADDING + drawHeight + 30 + canvasState.panY); + }); + + // Draw evolution axis line + rc.line( + PADDING + canvasState.panX, + PADDING + drawHeight + canvasState.panY, + PADDING + drawWidth + canvasState.panX, + PADDING + drawHeight + canvasState.panY, + { roughness: 1.2, stroke: "#666", strokeWidth: 1.5 } + ); + + // Draw evolution axis arrow and label + ctx.save(); + ctx.font = "16px Caveat, cursive, sans-serif"; + ctx.fillStyle = "#555"; + ctx.textAlign = "center"; + ctx.fillText("Evolution", PADDING + drawWidth / 2, height - 10); + ctx.restore(); + + // Draw value chain axis (Y-axis) + rc.line( + PADDING + canvasState.panX, + PADDING + canvasState.panY, + PADDING + canvasState.panX, + PADDING + drawHeight + canvasState.panY, + { roughness: 1.2, stroke: "#666", strokeWidth: 1.5 } + ); + + // Draw value chain labels + ctx.save(); + ctx.font = "14px Caveat, cursive, sans-serif"; + ctx.fillStyle = "#444"; + ctx.textAlign = "right"; + ctx.fillText("Visible", PADDING - 15 + canvasState.panX, PADDING + 5 + canvasState.panY); + ctx.fillText("Invisible", PADDING - 15 + canvasState.panX, PADDING + drawHeight + 5 + canvasState.panY); + + // Value chain label (rotated) + ctx.save(); + ctx.translate(15, PADDING + drawHeight / 2); + ctx.rotate(-Math.PI / 2); + ctx.font = "16px Caveat, cursive, sans-serif"; + ctx.fillStyle = "#555"; + ctx.textAlign = "center"; + ctx.fillText("Value Chain", 0, 0); + ctx.restore(); + ctx.restore(); + + // Draw dashed evolution stage separator lines + EVOLUTION_STAGES.slice(1).forEach((stage) => { + const x = PADDING + stage.position * drawWidth + canvasState.panX; + rc.line(x, PADDING + canvasState.panY, x, PADDING + drawHeight + canvasState.panY, { + roughness: 0.8, + stroke: "#ccc", + strokeWidth: 0.5, + strokeLineDash: [5, 5], + }); + }); + + // Draw connections + data.connections.forEach((conn) => { + const fromComponent = data.components.find((c) => c.id === conn.from); + const toComponent = data.components.find((c) => c.id === conn.to); + if (!fromComponent || !toComponent) return; + + const from = mapToCanvas(fromComponent.evolution, fromComponent.visibility, width, height); + const to = mapToCanvas(toComponent.evolution, toComponent.visibility, width, height); + + rc.line(from.x, from.y, to.x, to.y, { + roughness: 1.2, + stroke: canvasState.selectedId === conn.id ? "#3b82f6" : "#666", + strokeWidth: canvasState.selectedId === conn.id ? 2 : 1.5, + }); + + // Draw arrow head + const angle = Math.atan2(to.y - from.y, to.x - from.x); + const arrowLength = 10; + const arrowAngle = Math.PI / 6; + + const arrowX = to.x - COMPONENT_RADIUS * Math.cos(angle); + const arrowY = to.y - COMPONENT_RADIUS * Math.sin(angle); + + rc.line( + arrowX, + arrowY, + arrowX - arrowLength * Math.cos(angle - arrowAngle), + arrowY - arrowLength * Math.sin(angle - arrowAngle), + { roughness: 1, stroke: "#666", strokeWidth: 1.5 } + ); + rc.line( + arrowX, + arrowY, + arrowX - arrowLength * Math.cos(angle + arrowAngle), + arrowY - arrowLength * Math.sin(angle + arrowAngle), + { roughness: 1, stroke: "#666", strokeWidth: 1.5 } + ); + }); + + // Draw pipelines + data.pipelines.forEach((pipeline) => { + const component = data.components.find((c) => c.id === pipeline.componentId); + if (!component) return; + + const start = mapToCanvas(pipeline.evolutionStart, component.visibility, width, height); + const end = mapToCanvas(pipeline.evolutionEnd, component.visibility, width, height); + + rc.rectangle( + start.x, + start.y - 15, + end.x - start.x, + 30, + { + roughness: 1.5, + stroke: canvasState.selectedId === pipeline.id ? "#3b82f6" : "#888", + strokeWidth: canvasState.selectedId === pipeline.id ? 2 : 1, + fill: "rgba(200, 200, 200, 0.2)", + fillStyle: "hachure", + hachureGap: 8, + } + ); + }); + + // Draw components + data.components.forEach((component) => { + const pos = mapToCanvas(component.evolution, component.visibility, width, height); + const isSelected = canvasState.selectedId === component.id; + + // Draw component circle + rc.circle(pos.x, pos.y, COMPONENT_RADIUS * 2, { + roughness: 1.5, + stroke: isSelected ? "#3b82f6" : "#333", + strokeWidth: isSelected ? 2.5 : 2, + fill: component.inertia ? "#fbbf24" : "#fff", + fillStyle: "solid", + }); + + // Draw inertia indicator + if (component.inertia) { + rc.line(pos.x + COMPONENT_RADIUS + 5, pos.y, pos.x + COMPONENT_RADIUS + 20, pos.y, { + roughness: 1, + stroke: "#666", + strokeWidth: 1.5, + }); + rc.line(pos.x + COMPONENT_RADIUS + 15, pos.y - 5, pos.x + COMPONENT_RADIUS + 20, pos.y, { + roughness: 1, + stroke: "#666", + strokeWidth: 1.5, + }); + rc.line(pos.x + COMPONENT_RADIUS + 15, pos.y + 5, pos.x + COMPONENT_RADIUS + 20, pos.y, { + roughness: 1, + stroke: "#666", + strokeWidth: 1.5, + }); + } + + // Draw component label + ctx.font = "14px Caveat, cursive, sans-serif"; + ctx.fillStyle = "#333"; + ctx.textAlign = "left"; + ctx.fillText(component.name, pos.x + COMPONENT_RADIUS + 8, pos.y - 10); + }); + + // Draw anchors + data.anchors.forEach((anchor) => { + const pos = mapToCanvas(0, anchor.visibility, width, height); + + ctx.font = "bold 14px Caveat, cursive, sans-serif"; + ctx.fillStyle = "#666"; + ctx.textAlign = "left"; + ctx.fillText(anchor.name, pos.x - 50, pos.y); + + // Draw anchor bracket + rc.line(pos.x - 55, pos.y - 15, pos.x - 55, pos.y + 5, { + roughness: 1, + stroke: "#888", + strokeWidth: 1, + }); + }); + + // Draw title + ctx.font = "bold 24px Caveat, cursive, sans-serif"; + ctx.fillStyle = "#333"; + ctx.textAlign = "center"; + ctx.fillText(data.title, width / 2, 30); + + }, [data, canvasState, mapToCanvas]); + + // Handle resize + useEffect(() => { + const handleResize = () => { + const canvas = canvasRef.current; + const container = containerRef.current; + if (!canvas || !container) return; + + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + draw(); + }; + + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [draw]); + + // Redraw on data or state change + useEffect(() => { + draw(); + }, [draw]); + + // Mouse event handlers + const handleMouseDown = (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (canvasState.tool === "select") { + const component = findComponentAt(x, y, canvas.width, canvas.height); + if (component) { + setCanvasState((prev) => ({ ...prev, selectedId: component.id })); + setIsDragging(true); + setDragStart({ x, y }); + } else { + setCanvasState((prev) => ({ ...prev, selectedId: null })); + } + } else if (canvasState.tool === "component") { + const { evolution, visibility } = canvasToMap(x, y, canvas.width, canvas.height); + const newComponent: WardleyComponent = { + id: uuidv4(), + name: "New Component", + evolution, + visibility, + }; + onChange({ + ...data, + components: [...data.components, newComponent], + }); + setCanvasState((prev) => ({ ...prev, selectedId: newComponent.id, tool: "select" })); + setEditingComponent(newComponent.id); + setEditingName("New Component"); + } else if (canvasState.tool === "connection") { + const component = findComponentAt(x, y, canvas.width, canvas.height); + if (component) { + if (!connectionStart) { + setConnectionStart(component.id); + } else if (connectionStart !== component.id) { + const newConnection: WardleyConnection = { + id: uuidv4(), + from: connectionStart, + to: component.id, + }; + onChange({ + ...data, + connections: [...data.connections, newConnection], + }); + setConnectionStart(null); + setCanvasState((prev) => ({ ...prev, tool: "select" })); + } + } + } else if (canvasState.tool === "pan") { + setIsDragging(true); + setDragStart({ x, y }); + } + }; + + const handleMouseMove = (e: React.MouseEvent) => { + if (!isDragging) return; + + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (canvasState.tool === "select" && canvasState.selectedId) { + const { evolution, visibility } = canvasToMap(x, y, canvas.width, canvas.height); + const updatedComponents = data.components.map((c) => + c.id === canvasState.selectedId ? { ...c, evolution, visibility } : c + ); + onChange({ ...data, components: updatedComponents }); + } else if (canvasState.tool === "pan") { + const dx = x - dragStart.x; + const dy = y - dragStart.y; + setCanvasState((prev) => ({ + ...prev, + panX: prev.panX + dx, + panY: prev.panY + dy, + })); + setDragStart({ x, y }); + } + }; + + const handleMouseUp = () => { + setIsDragging(false); + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const component = findComponentAt(x, y, canvas.width, canvas.height); + if (component) { + setEditingComponent(component.id); + setEditingName(component.name); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Delete" || e.key === "Backspace") { + if (canvasState.selectedId && !editingComponent) { + // Delete selected component + const isComponent = data.components.some((c) => c.id === canvasState.selectedId); + if (isComponent) { + onChange({ + ...data, + components: data.components.filter((c) => c.id !== canvasState.selectedId), + connections: data.connections.filter( + (conn) => conn.from !== canvasState.selectedId && conn.to !== canvasState.selectedId + ), + }); + } else { + // Delete connection + onChange({ + ...data, + connections: data.connections.filter((c) => c.id !== canvasState.selectedId), + }); + } + setCanvasState((prev) => ({ ...prev, selectedId: null })); + } + } else if (e.key === "Escape") { + setConnectionStart(null); + setEditingComponent(null); + setCanvasState((prev) => ({ ...prev, tool: "select" })); + } + }; + + const handleNameSubmit = () => { + if (editingComponent) { + const updatedComponents = data.components.map((c) => + c.id === editingComponent ? { ...c, name: editingName } : c + ); + onChange({ ...data, components: updatedComponents }); + setEditingComponent(null); + } + }; + + const setTool = (tool: Tool) => { + setCanvasState((prev) => ({ ...prev, tool })); + setConnectionStart(null); + }; + + const toggleInertia = () => { + if (canvasState.selectedId) { + const updatedComponents = data.components.map((c) => + c.id === canvasState.selectedId ? { ...c, inertia: !c.inertia } : c + ); + onChange({ ...data, components: updatedComponents }); + } + }; + + return ( +
+ {/* Toolbar */} +
+
+ setTool("select")} + /> + setTool("component")} + /> + setTool("connection")} + /> + setTool("pan")} + /> +
+ +
+ + + + {connectionStart && ( + + Click target component to complete connection... + + )} +
+ + {/* Canvas container */} +
+ + + {/* Edit component name input */} + {editingComponent && ( +
+ + setEditingName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleNameSubmit(); + if (e.key === "Escape") setEditingComponent(null); + }} + className="w-64 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-amber-500" + autoFocus + /> +
+ + +
+
+ )} +
+ + {/* Status bar */} +
+ + {data.components.length} components, {data.connections.length} connections + + + Double-click to edit • Delete/Backspace to remove • Escape to cancel + +
+
+ ); +} + +interface ToolButtonProps { + icon: string; + label: string; + active: boolean; + onClick: () => void; +} + +function ToolButton({ icon, label, active, onClick }: ToolButtonProps) { + return ( + + ); +} diff --git a/examples/wardley-map/app/globals.css b/examples/wardley-map/app/globals.css new file mode 100644 index 0000000..71b1ced --- /dev/null +++ b/examples/wardley-map/app/globals.css @@ -0,0 +1,59 @@ +@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --background: #faf9f6; + --foreground: #333; +} + +body { + color: var(--foreground); + background: var(--background); + font-family: system-ui, -apple-system, sans-serif; +} + +/* Sketch font for labels */ +.font-sketch { + font-family: 'Caveat', cursive; +} + +/* Paper texture background */ +.paper-texture { + background-color: #faf9f6; + background-image: + linear-gradient(rgba(0, 0, 0, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 0, 0, 0.02) 1px, transparent 1px); + background-size: 20px 20px; +} + +/* Custom scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f5f4f0; +} + +::-webkit-scrollbar-thumb { + background: #d4c8a8; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: #c4b898; +} + +/* Canvas focus state */ +div:focus { + outline: none; +} + +/* Button transitions */ +button { + transition: all 0.15s ease; +} diff --git a/examples/wardley-map/app/layout.tsx b/examples/wardley-map/app/layout.tsx new file mode 100644 index 0000000..3073bc5 --- /dev/null +++ b/examples/wardley-map/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "Wardley Map - Strategic Mapping Tool", + description: + "Create and edit Wardley Maps with a sketch-style canvas for strategic planning and visualization", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/examples/wardley-map/app/page.tsx b/examples/wardley-map/app/page.tsx new file mode 100644 index 0000000..1285a91 --- /dev/null +++ b/examples/wardley-map/app/page.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useState } from "react"; +import { WardleyCanvas } from "./components/WardleyCanvas"; +import type { WardleyMapData } from "@/lib/types"; +import { v4 as uuidv4 } from "uuid"; + +// Sample data for demonstration +const SAMPLE_MAP: WardleyMapData = { + title: "Example Wardley Map", + components: [ + { id: uuidv4(), name: "User", evolution: 0.15, visibility: 0.95 }, + { id: uuidv4(), name: "Web Application", evolution: 0.45, visibility: 0.75 }, + { id: uuidv4(), name: "API Gateway", evolution: 0.55, visibility: 0.55 }, + { id: uuidv4(), name: "Database", evolution: 0.7, visibility: 0.35, inertia: true }, + { id: uuidv4(), name: "Cloud Hosting", evolution: 0.85, visibility: 0.15 }, + ], + connections: [], + pipelines: [], + anchors: [], +}; + +// Add connections based on component IDs +SAMPLE_MAP.connections = [ + { id: uuidv4(), from: SAMPLE_MAP.components[0].id, to: SAMPLE_MAP.components[1].id }, + { id: uuidv4(), from: SAMPLE_MAP.components[1].id, to: SAMPLE_MAP.components[2].id }, + { id: uuidv4(), from: SAMPLE_MAP.components[2].id, to: SAMPLE_MAP.components[3].id }, + { id: uuidv4(), from: SAMPLE_MAP.components[3].id, to: SAMPLE_MAP.components[4].id }, +]; + +const EMPTY_MAP: WardleyMapData = { + title: "Untitled Map", + components: [], + connections: [], + pipelines: [], + anchors: [], +}; + +export default function Home() { + const [mapData, setMapData] = useState(SAMPLE_MAP); + const [showSidebar, setShowSidebar] = useState(true); + + const handleTitleChange = (e: React.ChangeEvent) => { + setMapData((prev) => ({ ...prev, title: e.target.value })); + }; + + const handleNewMap = () => { + setMapData({ ...EMPTY_MAP, title: "Untitled Map" }); + }; + + const handleLoadSample = () => { + // Regenerate IDs for sample map + const newComponents = SAMPLE_MAP.components.map((c) => ({ ...c, id: uuidv4() })); + const newConnections = [ + { id: uuidv4(), from: newComponents[0].id, to: newComponents[1].id }, + { id: uuidv4(), from: newComponents[1].id, to: newComponents[2].id }, + { id: uuidv4(), from: newComponents[2].id, to: newComponents[3].id }, + { id: uuidv4(), from: newComponents[3].id, to: newComponents[4].id }, + ]; + setMapData({ + ...SAMPLE_MAP, + components: newComponents, + connections: newConnections, + }); + }; + + const handleExport = () => { + const dataStr = JSON.stringify(mapData, null, 2); + const blob = new Blob([dataStr], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `${mapData.title.replace(/\s+/g, "_")}.json`; + a.click(); + URL.revokeObjectURL(url); + }; + + const handleImport = () => { + const input = document.createElement("input"); + input.type = "file"; + input.accept = ".json"; + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0]; + if (file) { + const reader = new FileReader(); + reader.onload = (e) => { + try { + const data = JSON.parse(e.target?.result as string); + setMapData(data); + } catch { + alert("Invalid JSON file"); + } + }; + reader.readAsText(file); + } + }; + input.click(); + }; + + return ( +
+ {/* Sidebar */} + {showSidebar && ( +
+ {/* Header */} +
+

+ Wardley Map +

+

+ Strategic mapping tool +

+
+ + {/* Map title */} +
+ + +
+ + {/* Actions */} +
+ + +
+ + +
+
+ + {/* Legend */} +
+

Legend

+
+
+
+ Component +
+
+
+ Component with Inertia +
+
+
+ Dependency +
+
+ +

+ Evolution Stages +

+
+
+ Genesis + Novel, uncertain +
+
+ Custom + Emerging, learning +
+
+ Product + Feature competition +
+
+ Commodity + Utility, standard +
+
+
+ + {/* Footer */} +
+ Inspired by Simon Wardley's mapping technique +
+
+ )} + + {/* Main canvas area */} +
+ {/* Toggle sidebar button */} + + + {/* Canvas */} + +
+
+ ); +} diff --git a/examples/wardley-map/lib/types.ts b/examples/wardley-map/lib/types.ts new file mode 100644 index 0000000..8e00d9e --- /dev/null +++ b/examples/wardley-map/lib/types.ts @@ -0,0 +1,64 @@ +// Wardley Map Types + +export interface WardleyComponent { + id: string; + name: string; + // X position: 0 = Genesis, 1 = Commodity + evolution: number; + // Y position: 0 = Invisible (Infrastructure), 1 = Visible (User) + visibility: number; + // Optional properties + inertia?: boolean; + label?: string; +} + +export interface WardleyConnection { + id: string; + from: string; // Component ID + to: string; // Component ID +} + +export interface WardleyPipeline { + id: string; + componentId: string; + evolutionStart: number; + evolutionEnd: number; +} + +export interface WardleyAnchor { + id: string; + name: string; + visibility: number; +} + +export interface WardleyMapData { + title: string; + components: WardleyComponent[]; + connections: WardleyConnection[]; + pipelines: WardleyPipeline[]; + anchors: WardleyAnchor[]; +} + +export type Tool = "select" | "component" | "connection" | "pipeline" | "anchor" | "pan"; + +export interface CanvasState { + zoom: number; + panX: number; + panY: number; + selectedId: string | null; + tool: Tool; +} + +// Evolution stages on X-axis +export const EVOLUTION_STAGES = [ + { label: "Genesis", position: 0 }, + { label: "Custom", position: 0.25 }, + { label: "Product", position: 0.55 }, + { label: "Commodity", position: 0.85 }, +] as const; + +// Value chain labels on Y-axis +export const VALUE_CHAIN_LABELS = [ + { label: "Visible", position: 1 }, + { label: "Invisible", position: 0 }, +] as const; diff --git a/examples/wardley-map/next-env.d.ts b/examples/wardley-map/next-env.d.ts new file mode 100644 index 0000000..1b3be08 --- /dev/null +++ b/examples/wardley-map/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/examples/wardley-map/next.config.ts b/examples/wardley-map/next.config.ts new file mode 100644 index 0000000..cb651cd --- /dev/null +++ b/examples/wardley-map/next.config.ts @@ -0,0 +1,5 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = {}; + +export default nextConfig; diff --git a/examples/wardley-map/package.json b/examples/wardley-map/package.json new file mode 100644 index 0000000..f07ba87 --- /dev/null +++ b/examples/wardley-map/package.json @@ -0,0 +1,28 @@ +{ + "name": "wardley-map", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^15.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "roughjs": "^4.6.0", + "uuid": "^11.0.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/uuid": "^10.0.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.7.0" + } +} diff --git a/examples/wardley-map/postcss.config.mjs b/examples/wardley-map/postcss.config.mjs new file mode 100644 index 0000000..2ef30fc --- /dev/null +++ b/examples/wardley-map/postcss.config.mjs @@ -0,0 +1,9 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; diff --git a/examples/wardley-map/tailwind.config.ts b/examples/wardley-map/tailwind.config.ts new file mode 100644 index 0000000..1a2f4f9 --- /dev/null +++ b/examples/wardley-map/tailwind.config.ts @@ -0,0 +1,19 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + fontFamily: { + sketch: ["Caveat", "cursive"], + }, + }, + }, + plugins: [], +}; + +export default config; diff --git a/examples/wardley-map/tsconfig.json b/examples/wardley-map/tsconfig.json new file mode 100644 index 0000000..d81d4ee --- /dev/null +++ b/examples/wardley-map/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./*" + ] + }, + "target": "ES2017" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}