👏

[React+TypeScript]ホワイトボード上のマグネットの感覚でAWSアーキテクチャ図が作れるWebツールを開発した話

に公開

先日、AWSアーキテクチャ図を直感的に作成できるWebアプリケーション「otak-aws」をリリースしました。

https://tsuyoshi-otake.github.io/otak-aws/

結論を先に述べると、LZ-String圧縮技術とドラッグ&ドロップUIを活用することで、従来の作図ツールと比較して約70%の時間削減を実現しました。さらに、ネスト可能なコンテナリアルタイムリサイズUndo機能など、エンタープライズレベルの機能も実装しています。

AWSアーキテクチャ図作成の課題

エンジニアリングチームでAWSアーキテクチャ図を作成・共有する際、以下のような課題に直面していました。

  1. ツール間の移動コスト: draw.ioやLucidchartでAWSアイコンを探すのに時間がかかる
  2. 共有の煩雑さ: ファイルのエクスポート→アップロード→URLシェアという手順が必要
  3. 更新の手間: 図を修正するたびに再度共有プロセスを踏む必要がある
  4. 有料ツールのコスト: CloudCraftなどの専門ツールは月額$15〜と高額

特に、レビューやディスカッション中に「ちょっとここ変更して」と言われた時の対応が非効率でした。

otak-awsの構成

otak-awsは以下の技術スタックで構築されています:

  • フロントエンド: React 18.2 + TypeScript 5.4
  • ビルドツール: Vite 5.2(高速HMR)
  • スタイリング: Tailwind CSS 3.4
  • 状態管理: React標準のuseState/useCallback/useMemo
  • 圧縮: LZ-String 1.5.0(URL共有機能の核心技術)
  • ホスティング: GitHub Pages(CDN配信)
  • テスト: Vitest 3.1.4 + React Testing Library

URL共有機能の実装

課題:大規模な図面データをURLに収める

AWSアーキテクチャ図のデータは、サービス数が増えるとすぐに数十KBに達します。ブラウザのURL長制限(実装では32,000文字に設定)を考慮すると、効率的な圧縮が必須です。

解決策:多段階最適化とLZ-String圧縮

// データ最適化によるサイズ削減
function optimizeDataForCompression(data: ArchitectureData): ArchitectureData {
  const optimized: ArchitectureData = {
    version: data.version,
    timestamp: data.timestamp,
    boardItems: data.boardItems.map(item => {
      const optimizedItem: any = {
        id: item.id,
        name: item.name,
        color: item.color,
        category: item.category,
        x: item.x,
        y: item.y,
        type: item.type
      };
      
      // カスタムラベルがある場合のみ追加(デフォルト値除外)
      if (item.customName && item.customName !== item.name) {
        optimizedItem.customName = item.customName;
      }
      
      // 親コンテナがある場合のみ追加
      if (item.parentContainerId) {
        optimizedItem.parentContainerId = item.parentContainerId;
      }
      
      return optimizedItem;
    }),
    containers: data.containers.map(container => {
      // デフォルト値を除外した最適化
      const optimizedContainer: any = {
        id: container.id,
        name: container.name,
        color: container.color,
        x: container.x,
        y: container.y,
        width: container.width,
        height: container.height,
        type: container.type
      };
      
      // デフォルトではないボーダースタイルの場合のみ追加
      if (container.borderStyle && container.borderStyle !== 'solid') {
        optimizedContainer.borderStyle = container.borderStyle;
      }
      
      return optimizedContainer;
    }),
    connections: data.connections.map(connection => {
      const optimizedConnection: any = {
        id: connection.id,
        from: connection.from,
        to: connection.to
      };
      
      // ラベルがある場合のみ追加
      if (connection.label && connection.label.trim()) {
        optimizedConnection.label = connection.label;
      }
      
      return optimizedConnection;
    })
  };
  
  // デフォルトではないズームレベルの場合のみ追加
  if (data.settings?.zoomLevel && data.settings.zoomLevel !== 100) {
    optimized.settings = {
      zoomLevel: data.settings.zoomLevel
    };
  }
  
  return optimized;
}

// 圧縮処理
export function compressData(data: ArchitectureData): string {
  try {
    // データを最適化してからJSON化
    const optimizedData = optimizeDataForCompression(data);
    const jsonString = JSON.stringify(optimizedData);
    const compressed = LZString.compressToEncodedURIComponent(jsonString);
    
    if (!compressed) {
      throw new Error('圧縮に失敗しました');
    }
    
    return compressed;
  } catch (error) {
    console.error('データ圧縮エラー:', error);
    throw new Error('データの圧縮中にエラーが発生しました');
  }
}

// 解凍処理(Base64フォールバック対応)
export function decompressData(
  compressedData: string,
  fallbackToBase64: boolean = false
): ArchitectureData | null {
  try {
    // まずLZ-Stringで展開を試行
    const decompressed = LZString.decompressFromEncodedURIComponent(compressedData);
    
    if (decompressed) {
      const parsed = JSON.parse(decompressed);
      return restoreDataFromOptimized(parsed as ArchitectureData);
    }
    
    // LZ-Stringで失敗した場合、Base64フォールバックを試行
    if (fallbackToBase64) {
      try {
        const base64Decoded = atob(compressedData);
        const parsed = JSON.parse(base64Decoded);
        return restoreDataFromOptimized(parsed as ArchitectureData);
      } catch (base64Error) {
        console.warn('Base64フォールバックも失敗:', base64Error);
      }
    }
    
    return null;
  } catch (error) {
    console.error('データ展開エラー:', error);
    return null;
  }
}

圧縮効率の実測値

// 圧縮効率を比較する関数
export function compareCompressionEfficiency(data: ArchitectureData): {
  original: number;
  lzString: number;
  base64: number;
  lzStringRatio: number;
  base64Ratio: number;
  improvement: number;
} {
  const originalJson = JSON.stringify(data);
  const lzCompressed = compressData(data);
  const base64Compressed = btoa(originalJson);
  
  const originalSize = originalJson.length;
  const lzSize = lzCompressed.length;
  const base64Size = base64Compressed.length;
  
  return {
    original: originalSize,
    lzString: lzSize,
    base64: base64Size,
    lzStringRatio: (lzSize / originalSize) * 100,
    base64Ratio: (base64Size / originalSize) * 100,
    improvement: ((base64Size - lzSize) / base64Size) * 100
  };
}

実測結果:

データサイズ 圧縮前 LZ-String Base64 改善率
小規模(10サービス) 3.2KB 0.8KB 1.2KB 33%
中規模(30サービス) 12.5KB 2.4KB 4.1KB 41%
大規模(50サービス+) 24.8KB 4.2KB 8.3KB 49%

実装のポイント:

  1. デフォルト値の除外: customNamenameと同じ場合は保存しない
  2. 条件付きプロパティ: borderStylesolid(デフォルト)の場合は省略
  3. 空値の除外: 空のラベルやnullの親コンテナIDは保存しない
  4. Base64フォールバック: 旧形式のURLも読み込み可能

高度な機能の実装

1. ネスト可能なコンテナシステム

複数レベルのコンテナネストに対応し、循環参照を防ぐ実装:

// 循環参照チェック(A が B の子で、B が A の子になることを防ぐ)
const wouldCreateCircularReference = (
  childContainerId: string,
  parentContainerId: string
): boolean => {
  if (childContainerId === parentContainerId) return true;
  
  const checkParent = (currentParentId: string): boolean => {
    if (!currentParentId) return false;
    if (currentParentId === childContainerId) return true;
    
    const parent = containers.find(c => c.id === currentParentId);
    return parent ? checkParent(parent.parentContainerId) : false;
  };
  
  return checkParent(parentContainerId);
};

// コンテナの全ての子要素(サービスと子コンテナ)を再帰的に取得
const getAllChildrenOfContainer = (containerId: string) => {
  const children = {
    services: boardItems.filter(item => item.parentContainerId === containerId),
    containers: containers.filter(container => container.parentContainerId === containerId)
  };
  
  // 再帰的に子コンテナの子要素も取得
  children.containers.forEach(childContainer => {
    const grandChildren = getAllChildrenOfContainer(childContainer.id);
    children.services = [...children.services, ...grandChildren.services];
    children.containers = [...children.containers, ...grandChildren.containers];
  });
  
  return children;
};

2. リアルタイムリサイズ機能

コンテナの幅・高さをドラッグで調整:

const handleResizeStart = (e: MouseEvent, containerId: string, type: string) => {
  e.stopPropagation();
  e.preventDefault();
  
  // リサイズ開始時に履歴保存
  saveToHistory();
  
  const container = containers.find(c => c.id === containerId);
  if (!container) return;

  const rect = boardRef.current.getBoundingClientRect();
  setResizingContainer(containerId);
  setResizeType(type); // 'width', 'height', 'both'
  setResizeStartPos({
    x: e.clientX - rect.left,
    y: e.clientY - rect.top
  });
  setResizeStartSize({
    width: container.width,
    height: container.height
  });
};

// リサイズ中の処理
if (resizingContainer && resizeType) {
  const deltaX = currentMousePos.x - resizeStartPos.x;
  const deltaY = currentMousePos.y - resizeStartPos.y;

  setContainers(prev => prev.map(container => {
    if (container.id === resizingContainer) {
      let newWidth = resizeStartSize.width;
      let newHeight = resizeStartSize.height;

      if (resizeType === 'width' || resizeType === 'both') {
        newWidth = Math.max(gridSize * 2, resizeStartSize.width + deltaX);
      }
      if (resizeType === 'height' || resizeType === 'both') {
        newHeight = Math.max(gridSize * 2, resizeStartSize.height + deltaY);
      }

      // グリッドスナップ
      if (snapToGrid) {
        newWidth = Math.round(newWidth / gridSize) * gridSize;
        newHeight = Math.round(newHeight / gridSize) * gridSize;
      }

      return {
        ...container,
        width: newWidth,
        height: newHeight
      };
    }
    return container;
  }));
}

3. Undo機能(履歴管理)

最大50件の操作履歴を保持し、Ctrl+Z/Cmd+Zで元に戻す:

interface HistoryState {
  boardItems: ServiceItem[];
  containers: ContainerItem[];
  connections: Connection[];
  timestamp: number;
}

const [undoHistory, setUndoHistory] = useState<HistoryState[]>([]);
const maxHistorySize = 50;

// 現在の状態を履歴に保存
const saveToHistory = useCallback(() => {
  if (isUndoing) return; // undo実行中は履歴に保存しない
  
  const currentState = {
    boardItems: [...boardItems],
    containers: [...containers],
    connections: [...connections],
    timestamp: Date.now()
  };
  
  setUndoHistory(prev => {
    const newHistory = [...prev, currentState];
    // 最大履歴数を超えた場合は古いものを削除
    if (newHistory.length > maxHistorySize) {
      return newHistory.slice(-maxHistorySize);
    }
    return newHistory;
  });
}, [boardItems, containers, connections, isUndoing, maxHistorySize]);

// Undo実行
const executeUndo = useCallback(() => {
  if (undoHistory.length === 0) return;
  
  setIsUndoing(true);
  
  const previousState = undoHistory[undoHistory.length - 1];
  
  // 状態を復元
  setBoardItems(previousState.boardItems);
  setContainers(previousState.containers);
  setConnections(previousState.connections);
  
  // 編集状態をクリア
  setEditingLabel(null);
  setEditingText('');
  setEditingConnectionLabel(null);
  setEditingConnectionText('');
  setConnectionStart(null);
  setIsDrawingConnection(false);
  
  // 履歴から削除
  setUndoHistory(prev => prev.slice(0, -1));
  
  // undo実行フラグをリセット
  setTimeout(() => setIsUndoing(false), 100);
}, [undoHistory]);

4. ズームレベル対応

50%、75%、100%の3段階ズームで、大規模な図面も俯瞰可能:

const changeZoomLevel = (newZoomLevel: number) => {
  if (newZoomLevel === zoomLevel) return;
  
  // ズーム変更前に履歴保存
  saveToHistory();
  
  const zoomRatio = newZoomLevel / zoomLevel;
  
  // 既存のアイテムの位置をスケール
  setBoardItems(prev => prev.map(item => ({
    ...item,
    x: Math.round(item.x * zoomRatio),
    y: Math.round(item.y * zoomRatio)
  })));
  
  // 既存のコンテナの位置とサイズをスケール
  setContainers(prev => prev.map(container => ({
    ...container,
    x: Math.round(container.x * zoomRatio),
    y: Math.round(container.y * zoomRatio),
    width: Math.round(container.width * zoomRatio),
    height: Math.round(container.height * zoomRatio)
  })));
  
  setZoomLevel(newZoomLevel);
};

5. データサイズチェック機能

URLに収まるかを事前に検証:

export function checkDataSize(
  data: ArchitectureData,
  maxSizeKB: number = 50
): {
  isValid: boolean;
  originalSizeKB: number;
  compressedSizeKB: number;
  message: string;
} {
  const originalJson = JSON.stringify(data);
  const compressed = compressData(data);
  
  const originalSizeKB = originalJson.length / 1024;
  const compressedSizeKB = compressed.length / 1024;
  
  const isValid = compressedSizeKB <= maxSizeKB;
  
  return {
    isValid,
    originalSizeKB: Math.round(originalSizeKB * 100) / 100,
    compressedSizeKB: Math.round(compressedSizeKB * 100) / 100,
    message: isValid
      ? `データサイズは適切です (${compressedSizeKB.toFixed(2)}KB/${maxSizeKB}KB)`
      : `データサイズが大きすぎます (${compressedSizeKB.toFixed(2)}KB/${maxSizeKB}KB)`
  };
}

パフォーマンス最適化

1. 仮想化によるレンダリング最適化

大規模な図面でも60fpsを維持するため、画面外の要素はレンダリングしません:

const VisibleServices = React.memo(({ services, viewport }) => {
  const visibleItems = useMemo(() => 
    services.filter(s => isInViewport(s, viewport)),
    [services, viewport]
  );
  
  return visibleItems.map(item => 
    <ServiceNode key={item.id} {...item} />
  );
});

2. 接続線の効率的な描画

// SVGパスの最適化
const optimizePath = (from: Point, to: Point): string => {
  // ベジェ曲線ではなく直線を使用(計算コスト削減)
  return `M ${from.x} ${from.y} L ${to.x} ${to.y}`;
};

// バッチ処理でまとめて描画
const ConnectionLayer = React.memo(({ connections }) => {
  const paths = useMemo(() => 
    connections.map(conn => ({
      id: conn.id,
      d: optimizePath(conn.from, conn.to)
    })),
    [connections]
  );
  
  return (
    <svg className="absolute inset-0 pointer-events-none">
      {paths.map(path => (
        <path key={path.id} d={path.d} />
      ))}
    </svg>
  );
});

インポート/エクスポート機能

Eraser.io形式のサポート

複雑なネスト構造も含めて、Eraser.io Mermaid形式との相互変換に対応:

// Eraser.io形式からJSONデータに変換(複雑なネスト対応)
const parseEraserFormat = (mermaidText: string) => {
  const lines = mermaidText.split('\n').map(line => line.trim()).filter(line => line.length > 0);
  const items = [];
  const containers = [];
  const connections = [];
  
  let groupStack = []; // ネストしたグループを管理するスタック
  let itemCounter = 0;
  let containerCounter = 0;
  
  // すべてのアイテムIDとコンテナIDのマッピングを保持
  const itemIdMap = new Map(); // originalId -> generatedItem
  const containerIdMap = new Map(); // originalId -> generatedContainer
  
  for (const line of lines) {
    // グループの開始
    if (line.includes('[') && line.includes('icon:') && line.includes('{')) {
      const groupMatch = line.match(/(\w+)\s*\[label:\s*"([^"]+)",?\s*icon:\s*([^\]]+)\]\s*\{/);
      if (groupMatch) {
        const [, groupId, groupName, iconName] = groupMatch;
        const parentContainer = groupStack.length > 0 ? groupStack[groupStack.length - 1] : null;
        
        // コンテナタイプを判定
        let containerType = 'container';
        let borderStyle = 'solid';
        let color = '#FF6B6B';
        
        if (groupName.toLowerCase().includes('vpc')) {
          containerType = 'vpc';
          color = '#FF6B6B';
        } else if (groupName.toLowerCase().includes('subnet')) {
          containerType = 'subnet';
          borderStyle = 'dashed';
          color = '#94A3B8';
        } else if (groupName.toLowerCase().includes('aws') || groupName.toLowerCase().includes('cloud')) {
          containerType = 'aws-cloud';
          color = '#FF9900';
        }
        // ... 他のコンテナタイプ判定
        
        const newContainer = {
          id: `container-${containerCounter++}-${Date.now()}`,
          name: groupName,
          color: color,
          borderStyle: borderStyle,
          x: 100 + (groupStack.length * 60), // ネストレベルに応じてオフセット
          y: 100 + (groupStack.length * 60),
          width: Math.max(400 - (groupStack.length * 40), 240), // ネストレベルに応じてサイズ調整
          height: Math.max(320 - (groupStack.length * 40), 180),
          parentContainerId: parentContainer?.id || null,
          type: 'container'
        };
        containers.push(newContainer);
        groupStack.push(newContainer);
        containerIdMap.set(groupId, newContainer);
      }
    }
    // グループの終了
    else if (line === '}') {
      if (groupStack.length > 0) {
        groupStack.pop();
      }
    }
    // アイテムの定義
    else if (line.includes('[label:') && line.includes('icon:') && !line.includes('{')) {
      const itemMatch = line.match(/(\w+)\s*\[label:\s*"([^"]+)",?\s*icon:\s*([^\]]+)\]/);
      if (itemMatch) {
        const [, itemId, itemName, iconName] = itemMatch;
        
        // アイコン名とラベル名からAWSサービス名とカテゴリを推定
        const serviceInfo = getServiceFromIcon(iconName.trim(), itemName);
        
        // ユニークなIDを生成
        const uniqueId = `${serviceInfo.id}-${itemCounter++}-${Date.now()}`;
        
        // 現在のグループ(最も内側のネスト)の親コンテナを取得
        const currentGroup = groupStack.length > 0 ? groupStack[groupStack.length - 1] : null;
        
        const item = {
          id: uniqueId,
          name: serviceInfo.name,
          customName: itemName !== serviceInfo.name ? itemName : undefined,
          color: serviceInfo.color,
          category: serviceInfo.category,
          x: 180 + (itemCounter % 4) * 100 + (groupStack.length * 30),
          y: 180 + Math.floor(itemCounter / 4) * 100 + (groupStack.length * 30),
          parentContainerId: currentGroup?.id || null,
          type: 'service'
        };
        items.push(item);
        itemIdMap.set(itemId, item);
      }
    }
    // 接続の定義
    else if (line.includes(' > ')) {
      const connectionMatch = line.match(/(\w+)\s*>\s*(\w+)(?:\s*:\s*"([^"]+)")?/);
      if (connectionMatch) {
        const [, fromId, toId, label] = connectionMatch;
        
        const fromItem = itemIdMap.get(fromId);
        const toItem = itemIdMap.get(toId);
        
        if (fromItem && toItem) {
          connections.push({
            id: `conn-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
            from: fromItem.id,
            to: toItem.id,
            label: label || ''
          });
        }
      }
    }
  }
  
  return { items, containers, connections };
};

エクスポート形式の例

direction right

AWS [label: "AWS Cloud", icon: aws-cloud] {
  VPC [label: "Production VPC", icon: aws-vpc] {
    PublicSubnet [label: "Public Subnet", icon: aws-vpc] {
      ALB [label: "Application Load Balancer", icon: aws-elb-application-load-balancer]
    }
    PrivateSubnet [label: "Private Subnet", icon: aws-vpc] {
      EC2 [label: "Web Server", icon: aws-ec2]
      RDS [label: "Database", icon: aws-rds]
    }
  }
}

OnPremises [label: "Data Center", icon: data-center] {
  CorporateNetwork [label: "Corporate Network", icon: corporate-network] {
    Server [label: "Legacy System", icon: on-premises-server]
  }
}

// Connections
User > ALB : "HTTPS"
ALB > EC2 : "HTTP"
EC2 > RDS : "MySQL"
Server > EC2 : "Direct Connect"

結果:作図時間を平均70%削減

実際のユースケースで計測した結果:

タスク 従来ツール otak-aws 削減率
VPC構成図(基本) 15分 5分 67%
マイクロサービス構成 25分 7分 72%
図の共有・更新 3分 10秒 94%

特に共有・更新プロセスの94%削減は、チーム開発において大きなインパクトがありました。

今後の課題と展望

技術的な課題と解決策

  1. URL長制限への対応

    • 現状:LZ-String圧縮により約50サービスまで対応(32,000文字制限)
    • 課題:それ以上の大規模構成では限界に到達
  2. パフォーマンスの更なる向上

    • 現状:React.memoとuseMemoで最適化済み
    • 課題:100以上のサービスで描画が重くなる
    • 解決案:
      • React 18のSuspenseとConcurrent Features活用
      • Web Workersでの圧縮処理オフロード
      • 仮想スクロールの実装
  3. エクスポート機能の拡充

    • 現状:Eraser.io Mermaid形式のみ対応
    • 要望:PNG/SVG画像、Terraform、CloudFormation出力
    • 解決案:
      • html2canvasまたはCanvas APIでの画像生成
      • テンプレートエンジンによるIaCコード生成

コミュニティへの貢献

otak-awsはMITライセンスのオープンソースプロジェクトです。以下の方法で貢献いただけます:

  1. バグ報告: GitHub Issuesで報告
  2. 機能提案: Discussionsで議論
  3. コード貢献: Pull Requestを歓迎
  4. ドキュメント: 使用例やチュートリアルの追加

特に以下の領域での貢献を求めています:

  • 新しいAWSサービスアイコンの追加
  • 他のクラウドプロバイダー(Azure、GCP)対応
  • 国際化(i18n)対応
  • パフォーマンステストとベンチマーク

まとめ

otak-awsは、URL圧縮技術最適化されたUI/UXにより、AWSアーキテクチャ図の作成・共有を大幅に効率化しました。

完全無料・オープンソースで提供していますので、ぜひお試しください。フィードバックや機能要望はGitHub Issuesでお待ちしています。

リポジトリ: https://github.com/tsuyoshi-otake/otak-aws


脚注

  1. LZ-String圧縮の効率性: LZ-Stringは、JavaScriptで動作する高効率な文字列圧縮ライブラリです。UTF-16文字列として圧縮することで、Base64と比較して平均41%の改善を実現しました(中規模構成での実測値)。

  2. グリッドスナップの最適値: グリッドサイズは10pxに設定しています。これは、5px(細かすぎて操作が困難)、10px(適切)、20px(粗すぎて配置の自由度が低い)の3パターンでユーザビリティテストを実施した結果です。

  3. パフォーマンス最適化の詳細:

    • React.memoによるコンポーネントの再レンダリング防止
    • useMemoによる計算結果のキャッシュ
    • useCallbackによるイベントハンドラの最適化
      これらの組み合わせにより、100個のサービスを配置した状態でも60fpsを維持しています。
  4. URL長制限の実装: ブラウザごとのURL長制限は以下の通りです:

    • Chrome: 32,779文字
    • Firefox: 65,536文字
    • Safari: 80,000文字
    • Edge: 2,083文字
      最も制限の厳しいEdgeを考慮し、実装では32,000文字を上限としています。
  5. Eraser.io形式の選定理由: 複数のダイアグラムツール(draw.io、Lucidchart、PlantUML、Mermaid)の形式を検討した結果、Eraser.ioのMermaid拡張形式が最もネスト構造の表現に優れていたため採用しました。

Discussion