Blog

CODEX 

CODEXの使用検証として、デザイナーがあると助かるって思うデザイン支援アプリとか考えてみようかってことで、そのままCODEX画面に問いかけてみた。

「なんかデザイナーが使うアプリで、役に立つの開発したいな。」

そうすると、帰ってきた答えがこれ。

いいね。デザイナー向けなら「毎日ちょっと面倒だけど、地味に時間を食う作業」を潰すアプリが強そう。

ぱっと有望なのはこのあたりです。

  1. デザインレビュー用の差分チェッカー
    Figmaや画像をアップして、前回版との差分、余白ズレ、フォントサイズ変更、色の微差を見つける。
    エンジニアのコード差分みたいに、デザイン差分を見える化するやつ。
  2. ブランドガイド自動チェック
    画像やFigmaスクショを入れると、ブランドカラー外の色、違うフォント、余白ルール違反、角丸の不一致を検出する。
    チーム運用でかなり役立つ。
  3. UI文言のトーン調整ツール
    ボタン文言、エラー文、オンボーディング文を「やさしく」「業務向け」「高級感」「短く」などに変換。
    デザイナーがコピーで詰まる瞬間に刺さる。
  4. コンポーネント命名アシスタント
    スクショや用途から、Figmaコンポーネント名、variant名、property名を提案する。
    地味だけどデザインシステム運用者には嬉しい。
  5. アクセシビリティ即チェック
    画像や画面URLを入れると、コントラスト、文字サイズ、タップ領域、情報密度、色だけに依存している箇所を指摘する。
    「レビュー前の下見」として使える。
  6. ムードボードからUI方向性を作るツール
    参考画像を数枚入れると、色、タイポ、角丸、影、余白、ボタンの方向性を抽出して、UIキットっぽくまとめる。

個人的に一番「役に立つアプリ」になりやすいのは ブランドガイド自動チェックデザイン差分チェッカー だと思う。
デザイナー本人だけじゃなく、チーム・レビュー・納品・デザインシステム運用に効くので、使う理由がはっきりしてる。

作るなら最初のMVPはこれが良さそう:

「スクショをアップすると、UIのズレ・色・文字・余白をチェックして、修正リストを出すWebアプリ」

最初はFigma連携なしで、画像アップロードだけにすると作りやすい。あとからFigma API対応に広げられる。

なるほどな…。そんなサクッとアイディアまで出てくれるのか。

ということで、卒研テーマでもよく取り扱われるポスター制作の印象評価など実際、アンケートなどで評価することが多いけど、もっと定量的な結果求めたいよねと、まだまだ研究者としては研究の方法があまりない私自身も一回作ってみたくなったので、そのまま卒研でも使えそうなアプリを作成してみることにした。

「たとえば、卒業研究で、ポスターのデザイン評価をするシステムとか?
スクショやPDFとかPng, Jpegなんかアップして比較できるとか」

と投げかけると、

めちゃくちゃ良いと思う。卒業研究としても成立しやすいです。
「デザイナー向け便利ツール」より少し研究っぽくするなら、テーマはこう置けます。

ポスターデザイン評価・比較支援システム

アップロードしたポスター画像やPDFを解析して、デザインの観点ごとに評価・比較するシステム。

と、即応!! すごい。ありがとう!!

「デザインの良し悪しをAIが勝手に決める」より、人間が評価しやすくなるように補助するという立て方にすると、研究としても納得感が出ます。ってことだったので、

「じゃ、一回、ドラフトでそのプラットフォームを生成してもらおうかな?Webブラウザで完結する方がいいな。」

と、答えると、約3分弱でサクッと完成した。

そこから、CSV出ると便利だよね、とか、複数枚アップして、差分とか出せるといいよねとか、思いのまま追加機能を伝えると、概ね30分くらいのやり取りで、まずは試運転としては十分じゃないかというWebアプリが完成した。

ニーズがあればぜひ使用してみてください。(学生のみんな)

readme

# PosterScope

ポスターのデザイン評価と比較をブラウザ内で行う卒業研究向けプロトタイプです。

## できること

- PNG / JPEG / PDF の登録
- 複数案の並列比較
- 画像ポスターの自動スコア化
- 可読性、レイアウト、配色、視線誘導の軸別表示
- 改善コメントの生成
- CSVでの評価結果出力
- Attention Shift Scoreを含む比較差分レポート

## 評価の考え方

画像を小さく読み込み、余白量、エッジ密度、彩度、明暗差、画面内の重心バランス、注目重心を算出しています。
その特徴量をもとに、ポスター評価を支援するためのスコアとコメントを表示します。

Attention Shift Scoreは、2つのポスター間で注目重心がどれだけ移動したかを0から100で表す指標です。
値が大きいほど、見出し、図版、結論などの視覚的な優先順位が変化した可能性があります。

PDFはブラウザのプレビューで比較できます。自動評価まで行う場合は、PDFをPNGまたはJPEGに書き出して登録します。

## 使い方

`index.html` をWebブラウザで開き、ポスター案を追加します。

## CSV出力

評価CSVには、ファイル名、形式、総合点、可読性、レイアウト、配色、視線誘導のスコア、余白率、エッジ密度、彩度、重心バランス、注目重心、改善コメントが含まれます。

差分CSVには、全ポスター組み合わせごとのスコア差、Attention Shift Score、注目重心の移動方向、余白差、視覚密度差、色強調差、解釈コメントが含まれます。
ExcelやGoogleスプレッドシートに読み込むことで、人間の評価結果との比較、クロス集計、相関の確認に使えます。

app.js

const state = {
  posters: [],
  axes: new Set(["readability", "layout", "color", "attention"]),
};

const fileInput = document.querySelector("#fileInput");
const dropzone = document.querySelector("#dropzone");
const posterGrid = document.querySelector("#posterGrid");
const emptyState = document.querySelector("#emptyState");
const template = document.querySelector("#posterTemplate");
const fileCount = document.querySelector("#fileCount");
const avgScore = document.querySelector("#avgScore");
const clearButton = document.querySelector("#clearButton");
const exportButton = document.querySelector("#exportButton");
const exportComparisonButton = document.querySelector("#exportComparisonButton");
const exportStatus = document.querySelector("#exportStatus");
const comparisonPanel = document.querySelector("#comparisonPanel");
const comparisonGrid = document.querySelector("#comparisonGrid");
const comparisonNote = document.querySelector("#comparisonNote");

const labels = {
  readability: "可読性",
  layout: "レイアウト",
  color: "配色",
  attention: "視線誘導",
};

fileInput.addEventListener("change", (event) => {
  addFiles([...event.target.files]);
  fileInput.value = "";
});

dropzone.addEventListener("dragover", (event) => {
  event.preventDefault();
  dropzone.classList.add("is-dragging");
});

dropzone.addEventListener("dragleave", () => {
  dropzone.classList.remove("is-dragging");
});

dropzone.addEventListener("drop", (event) => {
  event.preventDefault();
  dropzone.classList.remove("is-dragging");
  addFiles([...event.dataTransfer.files]);
});

document.querySelectorAll(".axis").forEach((checkbox) => {
  checkbox.addEventListener("change", () => {
    if (checkbox.checked) state.axes.add(checkbox.value);
    else state.axes.delete(checkbox.value);
    render();
  });
});

clearButton.addEventListener("click", () => {
  state.posters.forEach((poster) => URL.revokeObjectURL(poster.url));
  state.posters = [];
  render();
});

exportButton.addEventListener("click", () => {
  downloadEvaluationCsv();
});

exportComparisonButton.addEventListener("click", () => {
  downloadComparisonCsv();
});

async function addFiles(files) {
  const accepted = files.filter((file) =>
    ["image/png", "image/jpeg", "application/pdf"].includes(file.type),
  );

  for (const file of accepted) {
    const url = URL.createObjectURL(file);
    const poster = {
      id: crypto.randomUUID(),
      name: file.name,
      type: file.type,
      url,
      scores: null,
      findings: [],
      palette: [],
    };
    state.posters.push(poster);

    if (file.type.startsWith("image/")) {
      try {
        const image = await loadImage(url);
        poster.scores = analyzeImage(image);
        poster.findings = buildFindings(poster.scores);
      } catch {
        poster.findings = [{ level: "bad", text: "画像を読み込めませんでした。" }];
      }
    } else {
      poster.scores = {
        readability: 54,
        layout: 58,
        color: 52,
        attention: 50,
        metrics: {
          whiteSpace: null,
          edgeDensity: null,
          saturation: null,
          balanceSpread: null,
          inkDensity: null,
          colorVariety: null,
          attentionX: null,
          attentionY: null,
        },
      };
      poster.findings = [
        { level: "warn", text: "PDFはプレビュー比較のみです。画像として書き出すと自動評価できます。" },
        { level: "good", text: "発表前の版管理や比較対象として登録されています。" },
      ];
    }
  }

  render();
}

function loadImage(src) {
  return new Promise((resolve, reject) => {
    const image = new Image();
    image.onload = () => resolve(image);
    image.onerror = reject;
    image.src = src;
  });
}

function analyzeImage(image) {
  const canvas = document.createElement("canvas");
  const size = 240;
  const ratio = image.naturalWidth / image.naturalHeight;
  canvas.width = ratio >= 1 ? size : Math.round(size * ratio);
  canvas.height = ratio >= 1 ? Math.round(size / ratio) : size;
  const context = canvas.getContext("2d", { willReadFrequently: true });
  context.drawImage(image, 0, 0, canvas.width, canvas.height);
  const { data, width, height } = context.getImageData(0, 0, canvas.width, canvas.height);

  let bright = 0;
  let dark = 0;
  let saturated = 0;
  let edge = 0;
  let ink = 0;
  let attentionWeight = 0;
  let attentionX = 0;
  let attentionY = 0;
  const quadrants = [0, 0, 0, 0];
  const buckets = new Set();

  for (let y = 0; y < height; y += 1) {
    for (let x = 0; x < width; x += 1) {
      const index = (y * width + x) * 4;
      const r = data[index];
      const g = data[index + 1];
      const b = data[index + 2];
      const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
      const max = Math.max(r, g, b);
      const min = Math.min(r, g, b);

      if (luminance > 232) bright += 1;
      if (luminance < 82) dark += 1;
      if (max - min > 72) saturated += 1;
      if (luminance < 210) ink += 1;

      const salience = Math.max(0, 180 - luminance) + Math.max(0, max - min - 42) * 1.2;
      if (salience > 0) {
        attentionWeight += salience;
        attentionX += (x / Math.max(1, width - 1)) * salience;
        attentionY += (y / Math.max(1, height - 1)) * salience;
      }

      const q = (x > width / 2 ? 1 : 0) + (y > height / 2 ? 2 : 0);
      quadrants[q] += luminance < 220 ? 1 : 0;
      buckets.add(`${r >> 5}-${g >> 5}-${b >> 5}`);

      if (x > 0 && y > 0) {
        const left = ((y * width + x - 1) * 4);
        const top = (((y - 1) * width + x) * 4);
        const leftLum = 0.2126 * data[left] + 0.7152 * data[left + 1] + 0.0722 * data[left + 2];
        const topLum = 0.2126 * data[top] + 0.7152 * data[top + 1] + 0.0722 * data[top + 2];
        if (Math.abs(luminance - leftLum) + Math.abs(luminance - topLum) > 82) edge += 1;
      }
    }
  }

  const total = width * height;
  const whiteSpace = bright / total;
  const darkRatio = dark / total;
  const saturation = saturated / total;
  const edgeDensity = edge / total;
  const inkDensity = ink / total;
  const colorVariety = Math.min(1, buckets.size / 180);
  const balanceSpread = (Math.max(...quadrants) - Math.min(...quadrants)) / Math.max(1, total / 4);
  const attentionCenterX = attentionWeight ? attentionX / attentionWeight : 0.5;
  const attentionCenterY = attentionWeight ? attentionY / attentionWeight : 0.5;

  const readability = scoreFrom([
    target(whiteSpace, 0.22, 0.46, 42),
    target(edgeDensity, 0.04, 0.17, 34),
    target(darkRatio, 0.04, 0.22, 24),
  ]);

  const layout = scoreFrom([
    invert(balanceSpread, 0.72, 44),
    target(inkDensity, 0.26, 0.62, 36),
    target(whiteSpace, 0.18, 0.5, 20),
  ]);

  const color = scoreFrom([
    target(saturation, 0.04, 0.24, 38),
    target(colorVariety, 0.1, 0.55, 32),
    target(darkRatio + whiteSpace, 0.3, 0.74, 30),
  ]);

  const attention = scoreFrom([
    target(edgeDensity, 0.05, 0.16, 34),
    target(saturation, 0.05, 0.2, 28),
    invert(balanceSpread, 0.92, 20),
    target(darkRatio, 0.05, 0.18, 18),
  ]);

  return {
    readability,
    layout,
    color,
    attention,
    metrics: {
      whiteSpace,
      edgeDensity,
      saturation,
      balanceSpread,
      inkDensity,
      colorVariety,
      attentionX: attentionCenterX,
      attentionY: attentionCenterY,
    },
  };
}

function target(value, min, max, weight) {
  if (value >= min && value <= max) return weight;
  const distance = value < min ? min - value : value - max;
  const range = Math.max(max - min, 0.01);
  return Math.max(0, weight * (1 - distance / range));
}

function invert(value, upper, weight) {
  return Math.max(0, weight * (1 - value / upper));
}

function scoreFrom(parts) {
  return Math.round(Math.max(0, Math.min(100, parts.reduce((sum, part) => sum + part, 0))));
}

function buildFindings(scores) {
  const m = scores.metrics;
  const findings = [];

  if (m.whiteSpace < 0.16) findings.push({ level: "bad", text: "余白が少なく、遠目で情報が詰まって見える可能性があります。" });
  else if (m.whiteSpace > 0.56) findings.push({ level: "warn", text: "余白が大きいため、情報量や視線の着地点を確認してください。" });
  else findings.push({ level: "good", text: "余白量は比較的安定しており、読み始めの負担が低そうです。" });

  if (m.edgeDensity > 0.2) findings.push({ level: "warn", text: "細かな要素が多く、本文や図表の密度が高い可能性があります。" });
  if (m.saturation > 0.28) findings.push({ level: "warn", text: "彩度の高い領域が多く、強調箇所が競合しているかもしれません。" });
  if (m.balanceSpread > 0.62) findings.push({ level: "warn", text: "画面内の重心に偏りがあります。上下左右の情報量を比べてください。" });
  if (scores.readability >= 74 && scores.layout >= 70) findings.push({ level: "good", text: "可読性と構成のバランスが良く、比較案の基準にしやすいです。" });

  return findings.slice(0, 4);
}

function render() {
  posterGrid.innerHTML = "";
  comparisonGrid.innerHTML = "";
  emptyState.hidden = state.posters.length > 0;
  fileCount.textContent = state.posters.length;
  exportButton.disabled = state.posters.length === 0;
  exportComparisonButton.disabled = state.posters.length < 2;
  exportStatus.textContent = state.posters.length
    ? "評価CSVは単体評価、差分CSVは全ペア比較を出力します。"
    : "評価結果を追加すると出力できます。";
  renderComparisons();

  const averages = state.posters.map((poster) => selectedAverage(poster.scores)).filter(Number.isFinite);
  avgScore.textContent = averages.length
    ? Math.round(averages.reduce((sum, score) => sum + score, 0) / averages.length)
    : "--";

  state.posters.forEach((poster) => {
    const node = template.content.firstElementChild.cloneNode(true);
    const preview = node.querySelector(".poster-preview");
    const name = node.querySelector(".poster-name");
    const type = node.querySelector(".poster-type");
    const score = node.querySelector(".score-badge span");
    const meterList = node.querySelector(".meter-list");
    const findingList = node.querySelector(".finding-list");

    name.textContent = poster.name;
    type.textContent = poster.type === "application/pdf" ? "PDF" : "Image";
    score.textContent = selectedAverage(poster.scores);

    if (poster.type === "application/pdf") {
      const object = document.createElement("object");
      object.data = poster.url;
      object.type = "application/pdf";
      object.innerHTML = `<p class="pdf-fallback">PDF preview</p>`;
      preview.append(object);
    } else {
      const image = document.createElement("img");
      image.src = poster.url;
      image.alt = poster.name;
      preview.append(image);
    }

    Object.entries(labels).forEach(([key, label]) => {
      if (!state.axes.has(key)) return;
      const value = poster.scores?.[key] ?? 0;
      const row = document.createElement("div");
      row.className = "meter-row";
      row.innerHTML = `
        <span>${label}</span>
        <span class="meter-track"><span class="meter-fill" style="width: ${value}%"></span></span>
        <strong>${value}</strong>
      `;
      meterList.append(row);
    });

    poster.findings.forEach((finding) => {
      const item = document.createElement("div");
      item.className = `finding ${finding.level}`;
      item.textContent = finding.text;
      findingList.append(item);
    });

    posterGrid.append(node);
  });
}

function selectedAverage(scores) {
  if (!scores) return "--";
  const selected = [...state.axes].map((axis) => scores[axis]).filter(Number.isFinite);
  if (!selected.length) return "--";
  return Math.round(selected.reduce((sum, value) => sum + value, 0) / selected.length);
}

function renderComparisons() {
  const comparisons = buildComparisons();
  comparisonPanel.hidden = comparisons.length === 0;
  comparisonNote.textContent = comparisons.length
    ? `${comparisons.length}件の比較を表示しています。`
    : "";

  comparisons.forEach((comparison) => {
    const card = document.createElement("article");
    card.className = "comparison-card";
    card.innerHTML = `
      <div class="comparison-title">
        <h3>${escapeHtml(comparison.base.name)} → ${escapeHtml(comparison.target.name)}</h3>
        <p>${comparison.summary}</p>
      </div>
      <div class="delta-grid">
        ${deltaItem("Attention Shift", comparison.attentionShiftScore, "注目重心の移動量", "score")}
        ${deltaItem("Density", comparison.densityDeltaPercent, "視覚密度差", "%")}
        ${deltaItem("Whitespace", comparison.whiteSpaceDeltaPercent, "余白差", "%")}
        ${deltaItem("Color Emphasis", comparison.colorEmphasisDeltaPercent, "色強調差", "%")}
      </div>
      <p class="delta-comment">${comparison.comment}</p>
    `;
    comparisonGrid.append(card);
  });
}

function deltaItem(label, value, help, unit) {
  const abs = Math.abs(value);
  const level = abs >= 20 ? "is-high" : abs >= 10 ? "is-medium" : "";
  const text = Number.isFinite(value) ? `${signed(value)}${unit === "%" ? "%" : ""}` : "--";
  return `
    <div class="delta-item ${level}">
      <span>${label}</span>
      <strong>${text}</strong>
      <small>${help}</small>
    </div>
  `;
}

function buildComparisons() {
  const comparisons = [];
  for (let i = 0; i < state.posters.length; i += 1) {
    for (let j = i + 1; j < state.posters.length; j += 1) {
      comparisons.push(comparePosters(state.posters[i], state.posters[j]));
    }
  }
  return comparisons;
}

function comparePosters(base, target) {
  const baseMetrics = base.scores?.metrics ?? {};
  const targetMetrics = target.scores?.metrics ?? {};
  const scoreDelta = delta(selectedAverage(target.scores), selectedAverage(base.scores));
  const readabilityDelta = delta(target.scores?.readability, base.scores?.readability);
  const layoutDelta = delta(target.scores?.layout, base.scores?.layout);
  const colorDelta = delta(target.scores?.color, base.scores?.color);
  const attentionDelta = delta(target.scores?.attention, base.scores?.attention);
  const whiteSpaceDeltaPercent = percentDelta(targetMetrics.whiteSpace, baseMetrics.whiteSpace);
  const densityDeltaPercent = percentDelta(targetMetrics.edgeDensity, baseMetrics.edgeDensity);
  const colorEmphasisDeltaPercent = percentDelta(targetMetrics.saturation, baseMetrics.saturation);
  const balanceDeltaPercent = percentDelta(targetMetrics.balanceSpread, baseMetrics.balanceSpread);
  const attentionShiftScore = attentionShift(baseMetrics, targetMetrics);
  const attentionDirection = attentionDirectionLabel(baseMetrics, targetMetrics);

  return {
    base,
    target,
    scoreDelta,
    readabilityDelta,
    layoutDelta,
    colorDelta,
    attentionDelta,
    whiteSpaceDeltaPercent,
    densityDeltaPercent,
    colorEmphasisDeltaPercent,
    balanceDeltaPercent,
    attentionShiftScore,
    attentionDirection,
    summary: `総合 ${signed(scoreDelta)} / 注目移動 ${Math.round(attentionShiftScore)}`,
    comment: buildComparisonComment({
      scoreDelta,
      whiteSpaceDeltaPercent,
      densityDeltaPercent,
      colorEmphasisDeltaPercent,
      balanceDeltaPercent,
      attentionShiftScore,
      attentionDirection,
    }),
  };
}

function buildComparisonComment(comparison) {
  const comments = [];
  if (comparison.attentionShiftScore >= 18) {
    comments.push(`注目重心が${comparison.attentionDirection}へ移動しています。見出し、図版、結論の優先順位が変わった可能性があります。`);
  }
  if (comparison.densityDeltaPercent >= 12) comments.push("視覚密度が上がっており、情報量は増えていますが遠目の可読性を確認したい案です。");
  if (comparison.densityDeltaPercent <= -12) comments.push("視覚密度が下がっており、整理された印象に近づいています。情報不足になっていないか確認してください。");
  if (comparison.whiteSpaceDeltaPercent <= -10) comments.push("余白が減っているため、本文や図表の詰まり感が増えている可能性があります。");
  if (comparison.whiteSpaceDeltaPercent >= 10) comments.push("余白が増えており、読み始めの負担は下がる一方で情報の密度は低く見える可能性があります。");
  if (comparison.colorEmphasisDeltaPercent >= 12) comments.push("色の強調が増えています。注目点が明確になったか、強調同士が競合していないかを見ると良さそうです。");
  if (!comments.length) comments.push("主要な視覚特徴は近く、差分は小さめです。人間評価では細部の文言や内容構成が差になりそうです。");
  return comments.slice(0, 2).join(" ");
}

function attentionShift(baseMetrics, targetMetrics) {
  const values = [baseMetrics.attentionX, baseMetrics.attentionY, targetMetrics.attentionX, targetMetrics.attentionY];
  if (!values.every(Number.isFinite)) return 0;
  const dx = targetMetrics.attentionX - baseMetrics.attentionX;
  const dy = targetMetrics.attentionY - baseMetrics.attentionY;
  return Math.round((Math.hypot(dx, dy) / Math.SQRT2) * 100);
}

function attentionDirectionLabel(baseMetrics, targetMetrics) {
  if (![baseMetrics.attentionX, baseMetrics.attentionY, targetMetrics.attentionX, targetMetrics.attentionY].every(Number.isFinite)) {
    return "不明";
  }
  const dx = targetMetrics.attentionX - baseMetrics.attentionX;
  const dy = targetMetrics.attentionY - baseMetrics.attentionY;
  const horizontal = Math.abs(dx) < 0.04 ? "" : dx > 0 ? "右" : "左";
  const vertical = Math.abs(dy) < 0.04 ? "" : dy > 0 ? "下" : "上";
  return `${vertical}${horizontal}` || "ほぼ同じ位置";
}

function downloadEvaluationCsv() {
  if (!state.posters.length) return;

  const headers = [
    "poster_id",
    "file_name",
    "file_type",
    "evaluation_type",
    "selected_average",
    "readability_score",
    "layout_score",
    "color_score",
    "attention_score",
    "white_space_ratio",
    "edge_density",
    "saturation_ratio",
    "balance_spread",
    "ink_density",
    "color_variety",
    "attention_center_x",
    "attention_center_y",
    "findings",
    "active_axes",
    "exported_at",
  ];

  const exportedAt = new Date().toISOString();
  const rows = state.posters.map((poster) => {
    const metrics = poster.scores?.metrics ?? {};
    return [
      poster.id,
      poster.name,
      poster.type,
      poster.type.startsWith("image/") ? "image_analysis" : "pdf_preview",
      selectedAverage(poster.scores),
      poster.scores?.readability,
      poster.scores?.layout,
      poster.scores?.color,
      poster.scores?.attention,
      formatMetric(metrics.whiteSpace),
      formatMetric(metrics.edgeDensity),
      formatMetric(metrics.saturation),
      formatMetric(metrics.balanceSpread),
      formatMetric(metrics.inkDensity),
      formatMetric(metrics.colorVariety),
      formatMetric(metrics.attentionX),
      formatMetric(metrics.attentionY),
      poster.findings.map((finding) => `[${finding.level}] ${finding.text}`).join(" | "),
      [...state.axes].join("|"),
      exportedAt,
    ];
  });

  const csv = [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
  downloadBlob(csv, `posterscope-evaluations-${dateStamp()}.csv`);
  exportStatus.textContent = "評価CSVを書き出しました。";
}

function downloadComparisonCsv() {
  const comparisons = buildComparisons();
  if (!comparisons.length) return;

  const headers = [
    "comparison_id",
    "base_file_name",
    "target_file_name",
    "score_delta",
    "readability_delta",
    "layout_delta",
    "color_delta",
    "attention_delta",
    "attention_shift_score",
    "attention_shift_direction",
    "white_space_delta_percent",
    "visual_density_delta_percent",
    "color_emphasis_delta_percent",
    "layout_balance_delta_percent",
    "base_attention_center_x",
    "base_attention_center_y",
    "target_attention_center_x",
    "target_attention_center_y",
    "interpretation",
    "exported_at",
  ];

  const exportedAt = new Date().toISOString();
  const rows = comparisons.map((comparison, index) => {
    const baseMetrics = comparison.base.scores?.metrics ?? {};
    const targetMetrics = comparison.target.scores?.metrics ?? {};
    return [
      `comparison_${index + 1}`,
      comparison.base.name,
      comparison.target.name,
      comparison.scoreDelta,
      comparison.readabilityDelta,
      comparison.layoutDelta,
      comparison.colorDelta,
      comparison.attentionDelta,
      comparison.attentionShiftScore,
      comparison.attentionDirection,
      formatMetric(comparison.whiteSpaceDeltaPercent),
      formatMetric(comparison.densityDeltaPercent),
      formatMetric(comparison.colorEmphasisDeltaPercent),
      formatMetric(comparison.balanceDeltaPercent),
      formatMetric(baseMetrics.attentionX),
      formatMetric(baseMetrics.attentionY),
      formatMetric(targetMetrics.attentionX),
      formatMetric(targetMetrics.attentionY),
      comparison.comment,
      exportedAt,
    ];
  });

  const csv = [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
  downloadBlob(csv, `posterscope-comparisons-${dateStamp()}.csv`);
  exportStatus.textContent = "差分CSVを書き出しました。";
}

function csvCell(value) {
  if (value === null || value === undefined) return "";
  const text = String(value).replaceAll('"', '""');
  return /[",\n]/.test(text) ? `"${text}"` : text;
}

function formatMetric(value) {
  return Number.isFinite(value) ? value.toFixed(4) : "";
}

function downloadBlob(csv, fileName) {
  const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const link = document.createElement("a");
  link.href = url;
  link.download = fileName;
  document.body.append(link);
  link.click();
  link.remove();
  URL.revokeObjectURL(url);
}

function delta(target, base) {
  return Number.isFinite(target) && Number.isFinite(base) ? Math.round(target - base) : 0;
}

function percentDelta(target, base) {
  return Number.isFinite(target) && Number.isFinite(base) ? (target - base) * 100 : 0;
}

function signed(value) {
  if (!Number.isFinite(value)) return "--";
  return value > 0 ? `+${Math.round(value)}` : `${Math.round(value)}`;
}

function escapeHtml(value) {
  return String(value)
    .replaceAll("&", "&amp;")
    .replaceAll("<", "&lt;")
    .replaceAll(">", "&gt;")
    .replaceAll('"', "&quot;")
    .replaceAll("'", "&#039;");
}

function dateStamp() {
  const now = new Date();
  const yyyy = now.getFullYear();
  const mm = String(now.getMonth() + 1).padStart(2, "0");
  const dd = String(now.getDate()).padStart(2, "0");
  const hh = String(now.getHours()).padStart(2, "0");
  const min = String(now.getMinutes()).padStart(2, "0");
  return `${yyyy}${mm}${dd}-${hh}${min}`;
}

render();

style.css

:root {
  color-scheme: light;
  --ink: #1f2428;
  --muted: #69717d;
  --line: #d7dde4;
  --panel: #ffffff;
  --paper: #f6f4ef;
  --accent: #156f73;
  --accent-2: #b34f2a;
  --good: #247a4d;
  --warn: #b66a18;
  --bad: #b04242;
  --shadow: 0 18px 50px rgba(38, 45, 54, 0.13);
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  min-height: 100vh;
  color: var(--ink);
  background:
    linear-gradient(180deg, rgba(246, 244, 239, 0.9), rgba(237, 241, 240, 0.95)),
    #eef1f0;
  font-family:
    Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
}

button,
input {
  font: inherit;
}

.app-shell {
  width: min(1440px, 100%);
  min-height: 100vh;
  margin: 0 auto;
  padding: 24px;
}

.topbar {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 20px;
  padding: 6px 2px 22px;
}

.eyebrow {
  margin: 0 0 4px;
  color: var(--accent);
  font-size: 13px;
  font-weight: 800;
  letter-spacing: 0;
}

h1,
h2,
p {
  margin: 0;
}

h1 {
  font-size: clamp(28px, 4vw, 54px);
  line-height: 1.02;
  letter-spacing: 0;
}

.summary-strip {
  display: grid;
  grid-template-columns: repeat(2, 88px);
  gap: 8px;
}

.summary-strip div {
  min-height: 64px;
  border: 1px solid var(--line);
  background: rgba(255, 255, 255, 0.72);
  display: grid;
  align-content: center;
  justify-items: center;
}

.summary-strip span {
  font-size: 24px;
  font-weight: 800;
}

.summary-strip small,
.poster-type,
.score-badge small {
  color: var(--muted);
  font-size: 12px;
}

.workspace {
  display: grid;
  grid-template-columns: 280px 1fr;
  gap: 18px;
  align-items: start;
}

.control-panel {
  position: sticky;
  top: 24px;
  display: grid;
  gap: 14px;
}

.dropzone,
.tool-group {
  border: 1px solid var(--line);
  background: rgba(255, 255, 255, 0.82);
  box-shadow: 0 10px 24px rgba(41, 48, 55, 0.06);
}

.dropzone {
  min-height: 180px;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 12px;
  cursor: pointer;
  transition: border-color 160ms ease, transform 160ms ease;
}

.dropzone:hover,
.dropzone.is-dragging {
  border-color: var(--accent);
  transform: translateY(-1px);
}

.dropzone input {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  opacity: 0;
  pointer-events: none;
}

.drop-icon {
  width: 46px;
  height: 46px;
  display: grid;
  place-items: center;
  border: 1px solid var(--line);
  color: var(--accent);
  font-size: 30px;
  line-height: 1;
}

.tool-group {
  padding: 18px;
}

.tool-group h2 {
  margin-bottom: 14px;
  font-size: 13px;
  color: var(--muted);
}

.tool-group label {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 34px;
  font-size: 14px;
}

.tool-group input {
  width: 16px;
  height: 16px;
  accent-color: var(--accent);
}

.tool-group.compact {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.tool-group.compact h2 {
  margin: 0;
}

.ghost-button {
  min-height: 36px;
  border: 1px solid var(--line);
  background: #fff;
  color: var(--ink);
  padding: 0 14px;
  cursor: pointer;
}

.ghost-button:disabled {
  color: #8a9498;
  cursor: not-allowed;
}

.full-button {
  width: 100%;
}

.primary-button {
  width: 100%;
  min-height: 42px;
  border: 1px solid var(--accent);
  background: var(--accent);
  color: #fff;
  font-weight: 750;
  cursor: pointer;
}

.primary-button:disabled {
  border-color: #c7d0d2;
  background: #d9e0e1;
  color: #7a8588;
  cursor: not-allowed;
}

.export-group {
  display: grid;
  gap: 12px;
}

.export-status {
  color: var(--muted);
  font-size: 12px;
  line-height: 1.45;
}

.main-panel {
  min-height: 640px;
}

.comparison-panel {
  margin-bottom: 18px;
  border: 1px solid var(--line);
  background: rgba(255, 255, 255, 0.78);
  box-shadow: var(--shadow);
  padding: 18px;
}

.section-heading {
  display: flex;
  align-items: end;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 16px;
}

.section-heading h2 {
  font-size: 22px;
}

.section-heading p:last-child {
  color: var(--muted);
  font-size: 13px;
}

.comparison-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 12px;
}

.comparison-card {
  border: 1px solid var(--line);
  background: #fff;
  padding: 14px;
  display: grid;
  gap: 14px;
}

.comparison-title {
  display: grid;
  gap: 4px;
}

.comparison-title h3 {
  margin: 0;
  font-size: 15px;
  line-height: 1.35;
  overflow-wrap: anywhere;
}

.comparison-title p {
  color: var(--muted);
  font-size: 12px;
}

.delta-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 8px;
}

.delta-item {
  border: 1px solid #e1e6e8;
  padding: 10px;
  display: grid;
  gap: 3px;
  min-height: 72px;
}

.delta-item strong {
  font-size: 19px;
}

.delta-item span,
.delta-item small {
  color: var(--muted);
  font-size: 12px;
}

.delta-item.is-high strong {
  color: var(--bad);
}

.delta-item.is-medium strong {
  color: var(--warn);
}

.delta-comment {
  border-left: 3px solid var(--accent-2);
  background: #fbf5f1;
  color: #463d36;
  padding: 9px 10px;
  font-size: 13px;
  line-height: 1.45;
}

.empty-state {
  min-height: 640px;
  border: 1px dashed #b9c2ca;
  display: grid;
  place-items: center;
  align-content: center;
  gap: 18px;
  color: var(--muted);
  background: rgba(255, 255, 255, 0.38);
}

.sample-sheet {
  width: min(280px, 58vw);
  aspect-ratio: 3 / 4;
  border: 1px solid var(--line);
  background: var(--panel);
  padding: 24px;
  display: grid;
  grid-template-rows: 42px 1fr 34px 34px;
  gap: 16px;
  box-shadow: var(--shadow);
}

.sample-sheet span {
  background: #dfe6e1;
}

.sample-sheet span:nth-child(2) {
  background: linear-gradient(90deg, #156f73 0 58%, #d8b04b 58% 100%);
}

.poster-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
  gap: 18px;
}

.poster-card {
  min-width: 0;
  border: 1px solid var(--line);
  background: var(--panel);
  box-shadow: var(--shadow);
  display: grid;
  grid-template-rows: minmax(360px, 58vh) auto;
}

.poster-preview {
  min-height: 360px;
  background: #e8eceb;
  display: grid;
  place-items: center;
  overflow: hidden;
}

.poster-preview img,
.poster-preview object {
  width: 100%;
  height: 100%;
  object-fit: contain;
  background: #f7f8f7;
}

.pdf-fallback {
  padding: 24px;
  color: var(--muted);
  text-align: center;
}

.poster-body {
  display: grid;
  gap: 16px;
  padding: 18px;
}

.poster-heading {
  display: flex;
  justify-content: space-between;
  gap: 16px;
  align-items: start;
}

.poster-name {
  max-width: 210px;
  overflow-wrap: anywhere;
  font-size: 17px;
}

.score-badge {
  width: 68px;
  min-width: 68px;
  aspect-ratio: 1;
  border: 1px solid var(--line);
  display: grid;
  place-items: center;
  align-content: center;
}

.score-badge span {
  font-size: 24px;
  font-weight: 850;
}

.meter-list {
  display: grid;
  gap: 10px;
}

.meter-row {
  display: grid;
  grid-template-columns: 76px 1fr 36px;
  align-items: center;
  gap: 10px;
  font-size: 13px;
}

.meter-track {
  height: 8px;
  background: #e4e8e6;
  overflow: hidden;
}

.meter-fill {
  height: 100%;
  background: var(--accent);
}

.finding-list {
  display: grid;
  gap: 8px;
}

.finding {
  border-left: 3px solid var(--accent);
  padding: 8px 10px;
  background: #f4f7f6;
  color: #394148;
  font-size: 13px;
  line-height: 1.45;
}

.finding.warn {
  border-color: var(--warn);
}

.finding.bad {
  border-color: var(--bad);
}

@media (max-width: 820px) {
  .app-shell {
    padding: 16px;
  }

  .topbar,
  .workspace {
    grid-template-columns: 1fr;
    display: grid;
  }

  .summary-strip {
    width: 100%;
    grid-template-columns: repeat(2, 1fr);
  }

  .control-panel {
    position: static;
  }

  .poster-grid {
    grid-template-columns: 1fr;
  }

  .section-heading {
    align-items: start;
    display: grid;
  }
}

index.html

<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>PosterScope</title>
    <link rel="stylesheet" href="./styles.css" />
  </head>
  <body>
    <main class="app-shell">
      <header class="topbar">
        <div>
          <p class="eyebrow">PosterScope</p>
          <h1>ポスターデザイン評価・比較</h1>
        </div>
        <div class="summary-strip" aria-live="polite">
          <div>
            <span id="fileCount">0</span>
            <small>files</small>
          </div>
          <div>
            <span id="avgScore">--</span>
            <small>avg</small>
          </div>
        </div>
      </header>

      <section class="workspace">
        <aside class="control-panel">
          <label class="dropzone" id="dropzone">
            <input id="fileInput" type="file" accept="image/png,image/jpeg,application/pdf" multiple />
            <span class="drop-icon" aria-hidden="true">+</span>
            <span>PDF / PNG / JPEG</span>
          </label>

          <div class="tool-group">
            <h2>評価軸</h2>
            <label><input type="checkbox" class="axis" value="readability" checked /> 可読性</label>
            <label><input type="checkbox" class="axis" value="layout" checked /> レイアウト</label>
            <label><input type="checkbox" class="axis" value="color" checked /> 配色</label>
            <label><input type="checkbox" class="axis" value="attention" checked /> 視線誘導</label>
          </div>

          <div class="tool-group compact">
            <h2>比較</h2>
            <button id="clearButton" class="ghost-button" type="button">全消去</button>
          </div>

          <div class="tool-group export-group">
            <h2>データ出力</h2>
            <button id="exportButton" class="primary-button" type="button" disabled>評価CSV</button>
            <button id="exportComparisonButton" class="ghost-button full-button" type="button" disabled>差分CSV</button>
            <p id="exportStatus" class="export-status">評価結果を追加すると出力できます。</p>
          </div>
        </aside>

        <section class="main-panel">
          <div id="emptyState" class="empty-state">
            <div class="sample-sheet">
              <span></span><span></span><span></span><span></span>
            </div>
            <p>ポスター案を追加すると、評価と比較がここに表示されます。</p>
          </div>

          <section id="comparisonPanel" class="comparison-panel" hidden>
            <div class="section-heading">
              <div>
                <p class="eyebrow">Comparison</p>
                <h2>差分レポート</h2>
              </div>
              <p id="comparisonNote"></p>
            </div>
            <div id="comparisonGrid" class="comparison-grid"></div>
          </section>

          <div id="posterGrid" class="poster-grid" aria-live="polite"></div>
        </section>
      </section>
    </main>

    <template id="posterTemplate">
      <article class="poster-card">
        <div class="poster-preview"></div>
        <div class="poster-body">
          <div class="poster-heading">
            <div>
              <h2 class="poster-name"></h2>
              <p class="poster-type"></p>
            </div>
            <div class="score-badge"><span></span><small>score</small></div>
          </div>
          <div class="meter-list"></div>
          <div class="finding-list"></div>
        </div>
      </article>
    </template>

    <script src="./app.js"></script>
  </body>
</html>

おすすめ

[instagram-feed]