jiwnchoi / Swipytics

Swipe is All You Need
https://jiwnchoi.github.io/Swipytics/
MIT License
2 stars 0 forks source link

Implement Kassel and Rohs 2017 #80

Closed jiwnchoi closed 55 minutes ago

jiwnchoi commented 4 days ago

Kassel and Rohs 2017을 baseline으로 구현하고자 함

image

Kassel and Rohs 2017 - Immersive navigation in visualization spaces through swipe gestures and optimal attribute selection.pdf

구현 내용

데드라인 11월 10일

jiwnchoi commented 4 days ago

클로드가 짜준 예시 코드

import React, { useState, useRef, useEffect, useMemo } from 'react';
import { ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Undo } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { LineChart, BarChart, ScatterChart, Line, Bar, Scatter, XAxis, YAxis, CartesianGrid, Tooltip, Legend } from 'recharts';
import _ from 'lodash';
import Papa from 'papaparse';

// 시각화 타입 정의
const CHART_TYPES = ['bar', 'line', 'scatter'];

const DataExplorer = () => {
  const [data, setData] = useState([]);
  const [attributes, setAttributes] = useState([]);
  const [selectedAttributes, setSelectedAttributes] = useState([]);
  const [chartType, setChartType] = useState('bar');
  const [chartOrientation, setChartOrientation] = useState('vertical');
  const [file, setFile] = useState(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const [touchStart, setTouchStart] = useState(null);
  const [touchEnd, setTouchEnd] = useState(null);
  const chartRef = useRef(null);

  // Helper functions for MMI calculation
  const calculateEntropy = (values) => {
    const total = values.length;
    const counts = _.countBy(values);

    return -Object.values(counts).reduce((sum, count) => {
      const probability = count / total;
      return sum + probability * Math.log2(probability);
    }, 0);
  };

  const calculateJointEntropy = (attributes) => {
    const jointDistribution = data.map(row => 
      attributes.map(attr => row[attr]).join('|')
    );

    return calculateEntropy(jointDistribution);
  };

  const calculateMMI = (attributes) => {
    if (attributes.length === 0) return 0;

    let mmi = 0;

    for (let k = 1; k <= attributes.length; k++) {
      const combinations = _.combinations(attributes, k);
      const term = combinations.reduce((sum, combination) => {
        return sum + calculateJointEntropy(combination);
      }, 0);

      mmi += Math.pow(-1, k - 1) * term;
    }

    return mmi;
  };

  const getDataType = (attribute) => {
    const values = data.map(row => row[attribute]);
    const uniqueValues = new Set(values);

    if (values.every(v => typeof v === 'number')) {
      return 'quantitative';
    }

    if (uniqueValues.size <= values.length * 0.05) {
      return 'nominal';
    }

    if (values.every(v => !isNaN(Date.parse(v)))) {
      return 'temporal';
    }

    return 'nominal';
  };

  // Memoize combination helper
  _.memoize.Cache = WeakMap;
  _.combinations = _.memoize((arr, k) => {
    const result = [];

    function combine(start, combo) {
      if (combo.length === k) {
        result.push([...combo]);
        return;
      }

      for (let i = start; i < arr.length; i++) {
        combo.push(arr[i]);
        combine(i + 1, combo);
        combo.pop();
      }
    }

    combine(0, []);
    return result;
  });

  // 키보드 이벤트 핸들러 추가
  useEffect(() => {
    const handleKeyDown = (e) => {
      if (!data.length) return; // 데이터가 없으면 무시

      switch(e.key) {
        case 'ArrowRight':
          handleRightSwipe();
          break;
        case 'ArrowLeft':
          handleLeftSwipe();
          break;
        case 'ArrowUp':
          handleUpSwipe();
          break;
        case 'ArrowDown':
          handleDownSwipe();
          break;
      }
    };

    window.addEventListener('keydown', handleKeyDown);
    return () => window.removeEventListener('keydown', handleKeyDown);
  }, [data, selectedAttributes]); // 의존성 배열 추가

  const handleFileUpload = async (event) => {
    const file = event.target.files[0];
    if (!file) return;

    if (!file.name.endsWith('.csv')) {
      setError('Please upload a CSV file');
      return;
    }

    setFile(file);
    setIsLoading(true);
    setError(null);

    try {
      const text = await file.text();
      Papa.parse(text, {
        header: true,
        dynamicTyping: true,
        skipEmptyLines: true,
        complete: (results) => {
          if (results.errors.length > 0) {
            setError('Error parsing CSV file');
            console.error(results.errors);
            return;
          }
          setData(results.data);
          setAttributes(Object.keys(results.data[0] || {}));
          setSelectedAttributes([]);
        },
        error: (error) => {
          setError('Error parsing CSV file');
          console.error(error);
        }
      });
    } catch (error) {
      setError('Error reading file');
      console.error(error);
    } finally {
      setIsLoading(false);
    }
  };

  const handleTouchStart = (e) => {
    setTouchStart({
      x: e.touches[0].clientX,
      y: e.touches[0].clientY
    });
  };

  const handleTouchEnd = (e) => {
    if (!touchStart) return;

    const touchEndPos = {
      x: e.changedTouches[0].clientX,
      y: e.changedTouches[0].clientY
    };
    setTouchEnd(touchEndPos);

    const dx = touchEndPos.x - touchStart.x;
    const dy = touchEndPos.y - touchStart.y;

    if (Math.abs(dx) > Math.abs(dy)) {
      if (dx > 50) handleRightSwipe();
      else if (dx < -50) handleLeftSwipe();
    } else {
      if (dy > 50) handleDownSwipe();
      else if (dy < -50) handleUpSwipe();
    }
  };

  const handleRightSwipe = () => {
    setChartOrientation(prev => prev === 'vertical' ? 'horizontal' : 'vertical');
  };

  const handleLeftSwipe = () => {
    const currentIndex = CHART_TYPES.indexOf(chartType);
    const nextIndex = (currentIndex + 1) % CHART_TYPES.length;
    setChartType(CHART_TYPES[nextIndex]);
  };

  const handleUpSwipe = () => {
    const unusedAttributes = attributes.filter(attr => !selectedAttributes.includes(attr));
    if (unusedAttributes.length > 0) {
      const mmiScores = unusedAttributes.map(attr => {
        const mmi = calculateMMI([...selectedAttributes, attr]);
        return { attribute: attr, score: mmi };
      });

      const bestAttribute = mmiScores.reduce((max, current) => 
        current.score > max.score ? current : max
      , mmiScores[0]);

      setSelectedAttributes([...selectedAttributes, bestAttribute.attribute]);
    }
  };

  const handleDownSwipe = () => {
    if (selectedAttributes.length > 1) {
      const subsets = selectedAttributes.map((_, index) => {
        const subset = selectedAttributes.filter((_, i) => i !== index);
        const mmi = calculateMMI(subset);
        return { subset, score: mmi };
      });

      const bestSubset = subsets.reduce((max, current) => 
        current.score > max.score ? current : max
      , subsets[0]);

      setSelectedAttributes(bestSubset.subset);
    }
  };

  const renderInitialMessage = () => {
    return (
      <div className="text-center p-8">
        <div className="mb-4 text-lg text-gray-600">
          Welcome to Data Explorer
        </div>
        <label className="block">
          <span className="sr-only">Choose CSV file</span>
          <input
            type="file"
            accept=".csv"
            onChange={handleFileUpload}
            className="block w-full text-sm text-gray-500
              file:mr-4 file:py-2 file:px-4
              file:rounded-full file:border-0
              file:text-sm file:font-semibold
              file:bg-blue-50 file:text-blue-700
              hover:file:bg-blue-100
              cursor-pointer"
          />
        </label>
        {error && (
          <div className="mt-4 text-red-500">
            {error}
          </div>
        )}
      </div>
    );
  };

  const renderVisualization = () => {
    if (selectedAttributes.length === 0) return null;

    const chartProps = {
      width: 600,
      height: 400,
      data: data,
      margin: { top: 20, right: 30, left: 20, bottom: 5 }
    };

    // 단일 속성일 경우 처리
    if (selectedAttributes.length === 1) {
      // 데이터 포인트의 개수를 계산
      const values = data.map(row => row[selectedAttributes[0]]);
      const processedData = _.countBy(values);
      const freqData = Object.entries(processedData).map(([value, count]) => ({
        value,
        count
      }));

      switch (chartType) {
        case 'line':
          return (
            <LineChart {...chartProps} data={freqData}>
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="value" />
              <YAxis dataKey="count" />
              <Tooltip />
              <Legend />
              <Line 
                type="monotone" 
                dataKey="count"
                name={selectedAttributes[0]}
                stroke="hsl(210, 70%, 50%)"
              />
            </LineChart>
          );

        case 'bar':
        default:
          return (
            <BarChart {...chartProps} data={freqData}>
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey="value" />
              <YAxis dataKey="count" />
              <Tooltip />
              <Legend />
              <Bar 
                dataKey="count"
                name={selectedAttributes[0]}
                fill="hsl(210, 70%, 50%)"
              />
            </BarChart>
          );
      }
    }

    // 다중 속성일 경우의 기존 로직
    switch (chartType) {
      case 'line':
        return (
          <LineChart {...chartProps}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey={selectedAttributes[0]} />
            <YAxis />
            <Tooltip />
            <Legend />
            {selectedAttributes.slice(1).map((attr, index) => (
              <Line 
                key={attr} 
                type="monotone" 
                dataKey={attr} 
                stroke={`hsl(${(index * 137.5) % 360}, 70%, 50%)`}
              />
            ))}
          </LineChart>
        );

      case 'bar':
        return (
          <BarChart {...chartProps}>
            <CartesianGrid strokeDasharray="3 3" />
            <XAxis dataKey={selectedAttributes[0]} />
            <YAxis />
            <Tooltip />
            <Legend />
            {selectedAttributes.slice(1).map((attr, index) => (
              <Bar 
                key={attr} 
                dataKey={attr} 
                fill={`hsl(${(index * 137.5) % 360}, 70%, 50%)`}
              />
            ))}
          </BarChart>
        );

      case 'scatter':
        if (selectedAttributes.length >= 2) {
          return (
            <ScatterChart {...chartProps}>
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey={selectedAttributes[0]} />
              <YAxis dataKey={selectedAttributes[1]} />
              <Tooltip />
              <Legend />
              <Scatter 
                name={`${selectedAttributes[0]} vs ${selectedAttributes[1]}`}
                data={data} 
                fill="#8884d8"
              />
            </ScatterChart>
          );
        } else {
          // 산점도는 2개 이상의 속성이 필요하므로 막대 차트로 폴백
          return (
            <BarChart {...chartProps}>
              <CartesianGrid strokeDasharray="3 3" />
              <XAxis dataKey={selectedAttributes[0]} />
              <YAxis />
              <Tooltip />
              <Legend />
              <Bar 
                dataKey={selectedAttributes[0]}
                fill="hsl(210, 70%, 50%)"
              />
            </BarChart>
          );
        }
    }
  };

  return (
    <div className="w-full h-full flex flex-col items-center justify-center bg-gray-100 p-4">
      <Card className="w-full max-w-4xl bg-white shadow-lg">
        <CardContent className="p-6">
          {isLoading ? (
            <div className="flex items-center justify-center h-[500px]">
              <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
            </div>
          ) : !data.length ? (
            renderInitialMessage()
          ) : (
            <div>
              {/* Attributes Selection - 상단에 배치 */}
              <div className="mb-6 p-4 bg-gray-50 rounded-lg border border-gray-200">
                <div className="text-sm font-medium text-gray-600 mb-2">Select Attributes</div>
                <div className="flex flex-wrap gap-2">
                  {attributes.map(attr => (
                    <button
                      key={attr}
                      className={`px-3 py-1 rounded-full text-sm ${
                        selectedAttributes.includes(attr)
                          ? 'bg-blue-500 text-white'
                          : 'bg-gray-200 text-gray-600'
                      }`}
                      onClick={() => {
                        if (selectedAttributes.includes(attr)) {
                          setSelectedAttributes(selectedAttributes.filter(a => a !== attr));
                        } else {
                          setSelectedAttributes([...selectedAttributes, attr]);
                        }
                      }}
                    >
                      {attr}
                    </button>
                  ))}
                </div>
              </div>

              {/* Visualization Area */}
              <div className="relative w-full h-[500px] flex items-center justify-center"
                   ref={chartRef}
                   onTouchStart={handleTouchStart}
                   onTouchEnd={handleTouchEnd}>
                <div className="absolute top-0 left-1/2 -translate-x-1/2 text-gray-400">
                  <ArrowUp className="w-6 h-6" />
                  <div className="text-sm">Add Information (↑)</div>
                </div>
                <div className="absolute bottom-0 left-1/2 -translate-x-1/2 text-gray-400">
                  <ArrowDown className="w-6 h-6" />
                  <div className="text-sm">Remove Information (↓)</div>
                </div>
                <div className="absolute left-0 top-1/2 -translate-y-1/2 text-gray-400">
                  <ArrowLeft className="w-6 h-6" />
                  <div className="text-sm">Change Type (←)</div>
                </div>
                <div className="absolute right-0 top-1/2 -translate-y-1/2 text-gray-400">
                  <ArrowRight className="w-6 h-6" />
                  <div className="text-sm">Change Orientation (→)</div>
                </div>

                <div className="w-full h-full flex items-center justify-center">
                  {renderVisualization()}
                </div>
              </div>

              {/* Controls Hint */}
              <div className="mt-4 text-sm text-gray-500 text-center">
                Use arrow keys or swipe gestures to navigate
              </div>
            </div>
          )}
        </CardContent>
      </Card>

      {file && (
        <div className="mt-4 text-sm text-gray-500">
          Current file: {file.name}
          <button 
            onClick={() => {
              setFile(null);
              setData([]);
              setAttributes([]);
              setSelectedAttributes([]);
              setError(null);
            }}
            className="ml-2 text-red-500 hover:text-red-700"
          >
            Remove
          </button>
        </div>
      )}
    </div>
  );
};

export default DataExplorer;