AI先生のロボットキャラクター
第7章 - セクション2

Qiitaニュースティッカー - ターミナル風記事フィード

Qiita APIから最新のAI関連記事を取得してターミナル風に表示。キーワード検索やストック数フィルターなど、実用的な機能を実装

男子生徒のアイコン

先生、Qiitaの記事をターミナル風に表示するパーツを作りたいんですけど、AIに頼めばできますか?

AI先生のアイコン

もちろん!実際にQiita APIと連携して、リアルタイムで記事を取得・表示するティッカーを作ってみたよ。キーワード検索やストック数でのフィルター機能も実装してあるんだ。

女子生徒のアイコン

Qiita APIって何ですか?難しそう…

AI先生のアイコン

Qiitaが公開しているプログラミング用の窓口のようなものだよ。これを使うと、Qiitaの記事データを自分のサイトに表示できるんだ。今回のティッカーでは、最新のAI関連記事を自動で取得して、ニュース速報のように流していくよ。

男子生徒のアイコン

ターミナル風ってどういう見た目なんですか?

AI先生のアイコン

黒背景に緑色の文字で、プログラマーがよく使うコマンドラインのような雰囲気だね。記事がタイピングアニメーションで表示されるから、まるで本物のターミナルでコマンドを打っているような臨場感があるよ。技術系のポートフォリオサイトとの相性が抜群なんだ。

作成したQiitaティッカーパーツ

Qiitaティッカー - Qiitaの記事をターミナル風に表示

このティッカーは、Qiita APIから最新のAI関連記事を自動取得し、ターミナル風のUIで表示する本格的なWebパーツです。

開発者向けのポートフォリオサイトやテックブログで、最新の技術記事を自動的に流し続けることができます。キーワードで絞り込んだり、ストック数の多い人気記事だけを表示したりと、用途に応じてカスタマイズが可能です。表示速度も0.5倍から10倍まで調整できるため、じっくり読みたい時は遅く、流し見したい時は速くといった使い分けができます。

ホバーでコントロールパネルが表示され、クリックやスクロールで一時停止できるなど、ユーザビリティにも配慮した設計になっています。

HTML
<div class="terminal-window">
    <div class="terminal-header">
        <div class="terminal-dots">
            <div class="dot close"></div>
            <div class="dot minimize"></div>
            <div class="dot maximize"></div>
        </div>
        <div class="terminal-title">Qiita Feed</div>
        <div class="terminal-controls">
            <div class="control-wrapper">
                <button class="control-icon" id="filter-toggle" title="フィルター">
                    <svg viewbox="0 0 24 24">
                        <path d="M6,13H18V11H6M3,6V8H21V6M10,18H14V16H10V18Z"></path>
                    </svg>
                </button>
                <div class="dropdown" id="filter-dropdown">
                    <div class="dropdown-title">Keyword</div>
                    <div class="dropdown-text">
                        <input type="text" id="filter-keyword" placeholder="Enterで検索" aria-label="キーワード検索">
                    </div>
                    <div class="dropdown-title">Stocks Filter</div>
                    <div class="dropdown-slider">
                        <input type="range" id="filter-slider" min="0" max="1000" step="5" value="0" aria-label="ストック数フィルター">
                        <div class="dropdown-value" id="filter-value-display">All</div>
                    </div>
                    <div class="dropdown-presets">
                        <button class="preset-btn active" data-filter="0">All</button>
                        <button class="preset-btn" data-filter="10">10+</button>
                        <button class="preset-btn" data-filter="50">50+</button>
                        <button class="preset-btn" data-filter="100">100+</button>
                        <button class="preset-btn" data-filter="500">500+</button>
                        <button class="preset-btn" data-filter="1000">1000+</button>
                    </div>
                </div>
            </div>
            <div class="control-wrapper">
                <button class="control-icon" id="speed-toggle" title="速度">
                    <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960" width="24px" fill="#e3e3e3">
                        <path d="M418-340q24 24 62 23.5t56-27.5l224-336-336 224q-27 18-28.5 55t22.5 61Zm62-460q59 0 113.5 16.5T696-734l-76 48q-33-17-68.5-25.5T480-720q-133 0-226.5 93.5T160-400q0 42 11.5 83t32.5 77h552q23-38 33.5-79t10.5-85q0-36-8.5-70T766-540l48-76q30 47 47.5 100T880-406q1 57-13 109t-41 99q-11 18-30 28t-40 10H204q-21 0-40-10t-30-28q-26-45-40-95.5T80-400q0-83 31.5-155.5t86-127Q252-737 325-768.5T480-800Zm7 313Z"></path>
                    </svg>
                </button>
                <div class="dropdown" id="speed-dropdown">
                    <div class="dropdown-title">Speed</div>
                    <div class="dropdown-slider">
                        <input type="range" id="speed-slider" min="0.5" max="10" step="0.5" value="1" aria-label="速度調整">
                        <div class="dropdown-value" id="speed-value-display">1.0x</div>
                    </div>
                    <div class="dropdown-presets">
                        <button class="preset-btn" data-speed="0.5">0.5x</button>
                        <button class="preset-btn active" data-speed="1">1x</button>
                        <button class="preset-btn" data-speed="2">2x</button>
                        <button class="preset-btn" data-speed="4">4x</button>
                        <button class="preset-btn" data-speed="6">6x</button>
                        <button class="preset-btn" data-speed="10">10x</button>
                    </div>
                </div>
            </div>
            <div class="control-wrapper">
                <button class="control-icon" id="lines-toggle" title="表示行数">
                    <svg xmlns="http://www.w3.org/2000/svg" height="24px" viewbox="0 -960 960 960" width="24px" fill="#e3e3e3">
                        <path d="M480-120 320-280l56-56 64 63v-414l-64 63-56-56 160-160 160 160-56 57-64-64v414l64-63 56 56-160 160Z"></path>
                    </svg>
                </button>
                <div class="dropdown" id="lines-dropdown">
                    <div class="dropdown-title">Display Lines</div>
                    <div class="dropdown-presets">
                        <button class="preset-btn active" data-lines="1">1</button>
                        <button class="preset-btn" data-lines="3">3</button>
                        <button class="preset-btn" data-lines="5">5</button>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div class="terminal-body" id="terminal-body">
        <div class="terminal-output">
            <div class="output-content" id="output-content"></div>
        </div>
    </div>
</div>
CSS
:root {
    --color-qiita-green: #55c500;
    --color-terminal-bg: #0d1117;
    --color-terminal-header: #161b22;
    --color-text: #c9d1d9;
    --color-text-dim: #8b949e;
    --color-prompt: #58a6ff;
    --color-success: #3fb950;
    --color-warning: #d29922;
    --space-sm: 0.5rem;
    --space-md: 1rem;
    --line-height: 1.6;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
    background: transparent;
    color: var(--color-text);
    min-height: 100vh;
    display: flex;
    align-items: flex-start;
    justify-content: center;
    padding: var(--space-md);
}

.terminal-window {
    width: 900px;
    max-width: 95vw;
    background: var(--color-terminal-bg);
    border-radius: 5px;
    box-shadow:
        0 20px 20px rgba(0, 0, 0, 0.6);
    overflow: visible;
    display: flex;
    flex-direction: column;
    margin: 20px;
}

/* ターミナルヘッダー */
.terminal-header {
    background: var(--color-terminal-header);
    padding: 0.75rem 1rem;
    border-radius: 12px 12px 0 0;
    display: flex;
    align-items: center;
    justify-content: space-between;
    border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}

.terminal-dots {
    display: flex;
    gap: 0.5rem;
    flex: 0 0 auto;
}

.terminal-title {
    flex: 1;
    text-align: center;
    font-size: 0.875rem;
    font-weight: 500;
    color: var(--color-text-dim);
}

.terminal-controls {
    flex: 0 0 auto;
}

.dot {
    width: 12px;
    height: 12px;
    border-radius: 50%;
}

.dot.close {
    background: #ff5f57;
}

.dot.minimize {
    background: #febc2e;
}

.dot.maximize {
    background: #28c840;
}

.terminal-controls {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s ease;
}

.terminal-window:hover .terminal-controls,
.terminal-controls.show {
    opacity: 1;
    pointer-events: all;
}

.control-icon {
    width: 32px;
    height: 32px;
    border: none;
    background: rgba(255, 255, 255, 0.05);
    color: var(--color-text-dim);
    border-radius: 6px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.2s;
    position: relative;
}

.control-icon:hover {
    background: rgba(85, 197, 0, 0.15);
    color: var(--color-qiita-green);
}

.control-icon.active {
    background: rgba(85, 197, 0, 0.2);
    color: var(--color-qiita-green);
}

.control-icon svg {
    width: 18px;
    height: 18px;
    fill: currentColor;
}

.control-wrapper {
    position: relative;
}

/* ドロップダウン */
.dropdown {
    position: absolute;
    top: calc(100% + 8px);
    right: 0;
    background: var(--color-terminal-header);
    border: 1px solid rgba(255, 255, 255, 0.1);
    border-radius: 8px;
    padding: 0.75rem;
    min-width: 280px;
    max-width: 90vw;
    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
    opacity: 0;
    pointer-events: none;
    transform: translateY(-8px);
    transition: all 0.2s;
    z-index: 1000;
}

/* フィルターは内容が増えるため縦スクロール可能にする */
#filter-dropdown {
    max-height: 95vh;
    overflow-y: auto;
}

.dropdown-text {
    margin-bottom: 1rem;
}

.dropdown-text input[type="text"] {
    width: 100%;
    padding: 0.6rem 0.65rem;
    border: 1px solid rgba(255, 255, 255, 0.1);
    background: rgba(255, 255, 255, 0.05);
    color: var(--color-text);
    border-radius: 6px;
    outline: none;
    font-size: 0.85rem;
}

.dropdown-text input[type="text"]:focus {
    border-color: var(--color-qiita-green);
    background: rgba(85, 197, 0, 0.08);
}

.dropdown.open {
    opacity: 1;
    pointer-events: all;
    transform: translateY(0);
}

.dropdown-title {
    font-size: 0.75rem;
    color: var(--color-text-dim);
    margin-bottom: 0.5rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

.dropdown-slider {
    margin-bottom: 0.75rem;
}

.dropdown-slider input[type="range"] {
    width: 100%;
    height: 4px;
    -webkit-appearance: none;
    appearance: none;
    background: rgba(255, 255, 255, 0.1);
    border-radius: 2px;
    outline: none;
}

.dropdown-slider input[type="range"]::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 16px;
    height: 16px;
    background: var(--color-qiita-green);
    border-radius: 50%;
    cursor: pointer;
}

.dropdown-slider input[type="range"]::-moz-range-thumb {
    width: 16px;
    height: 16px;
    background: var(--color-qiita-green);
    border: none;
    border-radius: 50%;
    cursor: pointer;
}

.dropdown-value {
    text-align: center;
    font-size: 1rem;
    color: var(--color-qiita-green);
    margin-top: 0.25rem;
    font-weight: 600;
}

.dropdown-presets {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 0.5rem;
}

.preset-btn {
    padding: 0.5rem;
    border: 1px solid rgba(255, 255, 255, 0.1);
    background: rgba(255, 255, 255, 0.05);
    color: var(--color-text);
    border-radius: 4px;
    cursor: pointer;
    font-size: 0.75rem;
    transition: all 0.2s;
}

.preset-btn:hover {
    background: rgba(85, 197, 0, 0.1);
    border-color: var(--color-qiita-green);
}

.preset-btn.active {
    background: var(--color-qiita-green);
    color: #000;
    border-color: var(--color-qiita-green);
}

/* ターミナル本体 */
.terminal-body {
    position: relative;
    /* heightはJavaScriptで動的に設定 */
}

.terminal-output {
    width: 100%;
    height: 100%;
    padding: 1.5rem;
    overflow-y: auto;
    overflow-x: hidden;
    font-size: 1rem;
    line-height: 2;
    /* 行間を広げる */
}

/* スクロールバーのスタイリング */
.terminal-output::-webkit-scrollbar {
    width: 8px;
}

.terminal-output::-webkit-scrollbar-track {
    background: rgba(255, 255, 255, 0.05);
}

.terminal-output::-webkit-scrollbar-thumb {
    background: transparent;
    border-radius: 4px;
    transition: background 0.3s ease;
}

/* ホバー時またはスクロール中にスクロールバーを表示 */
.terminal-window:hover .terminal-output::-webkit-scrollbar-thumb,
.terminal-output.scrolling::-webkit-scrollbar-thumb {
    background: var(--color-qiita-green);
}

.terminal-output::-webkit-scrollbar-thumb:hover {
    background: var(--color-success);
}

.output-content {
    width: 100%;
}

.terminal-line {
    margin-bottom: 0.5rem;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    line-height: 2;
    min-height: 2em;
    display: flex;
    align-items: center;
}

.article-item {
    height: 150px;
    /* 1記事の固定高さ */
    overflow: hidden;
    margin-bottom: 0.5rem;
    padding-bottom: 1rem;
    will-change: transform, opacity;
}

.article-item.is-entering {
    animation: article-enter 220ms ease-out both;
}

@keyframes article-enter {
    from {
        opacity: 0;
        transform: translateY(6px);
    }

    to {
        opacity: 1;
        transform: translateY(0);
    }
}

.article-title-line {
    margin-top: 0.5rem;
    margin-bottom: 0.5rem;
    font-size: 1.1rem;
    white-space: normal;
    word-wrap: break-word;
    overflow: visible;
    align-items: flex-start;
}

.article-meta-line {
    margin-bottom: 0.5rem;
    white-space: nowrap;
}

.prompt {
    color: var(--color-prompt);
    margin-right: 0.25rem;
    font-size: 0.9em;
    line-height: 1;
    flex-shrink: 0;
}

.command {
    color: var(--color-qiita-green);
    line-height: 1;
}

/* タイピングアニメーション */
.typing-command {
    display: inline-block;
    overflow: hidden;
    border-right: 2px solid var(--color-qiita-green);
    white-space: nowrap;
    line-height: 1;
    --typing-chars: 5ch;
    animation: typing 1.5s steps(5) forwards, blink-caret 0.75s step-end infinite;
}

@keyframes typing {
    from {
        width: 0;
    }

    to {
        width: var(--typing-chars, 5ch);
    }
}

@keyframes blink-caret {
    50% {
        border-color: transparent;
    }
}

/* チェックマーク */
.check-mark {
    color: #4ade80;
    font-weight: bold;
    margin-right: 0.5rem;
}

.article-title {
    color: var(--color-qiita-green);
    text-decoration: none;
    transition: color 0.2s;
    font-size: 1.1rem;
    font-weight: 600;
    display: inline-block;
}

.article-title:hover {
    color: var(--color-success);
    text-decoration: underline;
}

.meta {
    color: var(--color-text-dim);
    font-size: 0.85rem;
    opacity: 0.8;
}

.metric {
    font-weight: 600;
    margin-right: 0.25rem;
    line-height: 1;
    vertical-align: middle;
}

.metric-like {
    color: var(--color-text);
}

.metric-stock {
    color: var(--color-text);
    position: relative;
    top: -0.15em;
}

.meta-separator {
    margin: 0 0.5rem;
    color: var(--color-text-dim);
    opacity: 0.5;
}

.success {
    color: var(--color-success);
}

/* 選択肢リスト(next/quit) */
.choice-prompt {
    color: var(--color-text);
    font-size: 0.95rem;
    margin-bottom: 0.5rem;
}

.choice-line {
    margin: 0.75rem 0;
    display: block;
}

.choice-item {
    padding: 0.3rem 0;
    background: transparent;
    color: var(--color-text);
    cursor: pointer;
    font-size: 0.95rem;
    transition: color 0.15s;
    display: block;
    border: none;
    margin-left: 1rem;
}

.choice-item:hover {
    color: var(--color-qiita-green);
}

.choice-item.active {
    color: var(--color-qiita-green);
}

.choice-cursor {
    display: inline-block;
    width: 1rem;
    color: var(--color-qiita-green);
    visibility: hidden;
}

.choice-item.active .choice-cursor {
    visibility: visible;
}

.choice-number {
    color: var(--color-text-dim);
    margin-right: 0.25rem;
}

.choice-label {
    color: inherit;
}

.choice-description {
    display: none;
}

/* レスポンシブ */
@media (max-width: 768px) {
    .terminal-window {
        border-radius: 8px;
    }

    .terminal-output {
        font-size: 0.9rem;
        padding: 1rem;
    }

    .article-title {
        font-size: 1rem;
    }

    .meta {
        font-size: 0.75rem;
    }


    .terminal-title span {
        display: none;
    }

    .dropdown {
        right: 0;
        min-width: 240px;
        max-width: calc(100vw - 2rem);
    }
}

@media (max-width: 480px) {
    .terminal-dots {
        display: none;
    }

    .terminal-output {
        font-size: 0.85rem;
    }
}

.error-message {
    padding: 2rem;
    text-align: center;
    color: var(--color-warning);
}

.loading {
    padding: 2rem;
    text-align: center;
    color: var(--color-text-dim);
}

.loading::after {
    content: '';
    display: inline-block;
    width: 1em;
    height: 1em;
    border: 2px solid var(--color-qiita-green);
    border-top-color: transparent;
    border-radius: 50%;
    margin-left: 0.5rem;
    animation: spin 0.8s linear infinite;
}

@keyframes spin {
    to {
        transform: rotate(360deg);
    }
}
JavaScript
// 設定
        const CONFIG = {
            API_URL: 'https://qiita.com/api/v2/items',
            DATA_SOURCE: 'qiita',
            JSON_DATA: [],
            QUERY_PARAMS: {
                query: 'tag:AI,生成AI,AIエージェント,LLM',
                per_page: 50
            },
            SCROLL_SPEED_PX_PER_SEC: 50,
            UPDATE_INTERVAL: 30 * 60 * 1000,
            DEFAULT_LINES: 1
        };

        const STORAGE_KEY = 'pa-tu:qiita-terminal:v1';

        // DOM要素
        const outputContent = document.getElementById('output-content');
        const speedToggle = document.getElementById('speed-toggle');
        const filterToggle = document.getElementById('filter-toggle');
        const linesToggle = document.getElementById('lines-toggle');
        const speedDropdown = document.getElementById('speed-dropdown');
        const filterDropdown = document.getElementById('filter-dropdown');
        const linesDropdown = document.getElementById('lines-dropdown');
        const speedSlider = document.getElementById('speed-slider');
        const speedValueDisplay = document.getElementById('speed-value-display');
        const filterSlider = document.getElementById('filter-slider');
        const filterValueDisplay = document.getElementById('filter-value-display');
        const filterKeywordInput = document.getElementById('filter-keyword');

        // 状態
        const STATE = {
            rafId: null,
            lastTs: 0,
            running: false,
            speed: 1,
            minStocks: 0,
            keyword: '',
            restarting: false,
            displayLines: CONFIG.DEFAULT_LINES,
            y: 0,
            contentHeight: 0,
            viewHeight: 0,
            articles: [],
            currentPage: 1,
            displayTimerId: null,  // タイマーIDを保存
            displayPaused: false,  // 表示一時停止フラグ
            currentArticleIndex: 0, // 現在表示中の記事インデックス
            canPause: false        // コマンド入力中は停止不可にするフラグ
        };

        if (filterKeywordInput && typeof filterKeywordInput.value === 'string') {
            STATE.keyword = filterKeywordInput.value;
        }

        let keywordIsComposing = false;

        // 現在の表示ループ参照
        let currentDisplayLoop = null;

        // ユーティリティ
        function escapeHtml(text) {
            const map = {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'};
            return String(text).replace(/[&<>"']/g, m => map[m]);
        }

        function scrollElementToTopInContainer(container, element) {
            if (!container || !element) return;
            const containerRect = container.getBoundingClientRect();
            const elementRect = element.getBoundingClientRect();
            container.scrollTop += (elementRect.top - containerRect.top);
        }

        function formatNumber(num) {
            if (!Number.isFinite(Number(num))) return '0';
            return num >= 1000 ? `${(num / 1000).toFixed(1)}k` : String(num);
        }

        function getRelativeTime(dateString) {
            const date = new Date(dateString);
            if (!dateString || isNaN(date.getTime())) return '';
            const diff = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000));
            if (diff < 60) return '数秒前';
            if (diff < 3600) return `${Math.floor(diff / 60)}分前`;
            if (diff < 86400) return `${Math.floor(diff / 3600)}時間前`;
            if (diff < 2592000) return `${Math.floor(diff / 86400)}日前`;
            return `${Math.floor(diff / 2592000)}ヶ月前`;
        }

        function getTypingDurationMs(commandText) {
            const length = Math.max(1, String(commandText || '').length);
            // 固定1.5sだった頃の「長いコマンド時の体感速度」に近づける
            // (短いコマンドほど遅く感じる問題を避ける)
            const cps = 15;
            const minMs = 180;
            const maxMs = 1500; // 従来の上限(長くしない)
            const estimated = Math.round((length / cps) * 1000);
            return Math.max(minMs, Math.min(maxMs, estimated));
        }

        function buildArticleMetaHTML(article) {
            const time = getRelativeTime(article.createdAt);

            const metaParts = [];
            if (article.author) metaParts.push(`@${escapeHtml(article.author)}`);
            metaParts.push(`<span class="metric metric-like">♥</span>${formatNumber(article.likes ?? 0)}`);
            metaParts.push(`<span class="metric metric-stock">■</span>${formatNumber(article.stocks ?? 0)}`);
            if (time) metaParts.push(time);

            return metaParts.join('<span class="meta-separator">|</span>');
        }

        function buildArticleLinesHTML(article) {
            const titleEscaped = escapeHtml(article.title);
            const urlEscaped = escapeHtml(article.url);
            const metaString = buildArticleMetaHTML(article);

            return `<div class="terminal-line article-title-line">
<span class="check-mark">✓</span> <a href="${urlEscaped}" target="_blank" rel="noopener" class="article-title">${titleEscaped}</a>
</div>
<div class="terminal-line article-meta-line"><span class="meta">  ${metaString}</span></div>`;
        }

        // データ取得
        async function fetchArticles(page = 1) {
            try {
                const hasKeyword = typeof STATE.keyword === 'string' && STATE.keyword.trim() !== '';
                let query = hasKeyword ? STATE.keyword.trim() : CONFIG.QUERY_PARAMS.query;
                if (STATE.minStocks > 0) query += ` stocks:>${STATE.minStocks}`;
                const url = `${CONFIG.API_URL}?query=${encodeURIComponent(query)}&per_page=${CONFIG.QUERY_PARAMS.per_page}&page=${page}`;
                const response = await fetch(url);
                if (!response.ok) throw new Error(`HTTP ${response.status}`);
                const items = await response.json();
                return items.map(item => ({
                    title: item.title || '',
                    url: item.url || '',
                    author: item.user?.id || '',
                    likes: item.likes_count || 0,
                    stocks: item.stocks_count || 0,
                    createdAt: item.created_at || ''
                }));
            } catch (error) {
                console.error('記事取得失敗:', error);
                return [];
            }
        }

        // UI制御
        function updateSpeedValue() {
            const displayValue = `${STATE.speed.toFixed(1)}x`;
            if (speedValueDisplay) speedValueDisplay.textContent = displayValue;
            if (speedSlider && speedSlider.value !== String(STATE.speed)) {
                speedSlider.value = String(STATE.speed);
            }
            // プリセットボタンのactive状態を更新
            document.querySelectorAll('#speed-dropdown .preset-btn').forEach(btn => {
                const btnSpeed = parseFloat(btn.dataset.speed);
                btn.classList.toggle('active', Math.abs(btnSpeed - STATE.speed) < 0.01);
            });
        }

        function updateFilterValue() {
            const displayValue = STATE.minStocks === 0 ? 'All' : `${STATE.minStocks}+`;
            if (filterValueDisplay) filterValueDisplay.textContent = displayValue;
            if (filterSlider && filterSlider.value !== String(STATE.minStocks)) {
                filterSlider.value = String(STATE.minStocks);
            }
            // プリセットボタンのactive状態を更新
            document.querySelectorAll('#filter-dropdown .preset-btn').forEach(btn => {
                const btnStocks = parseInt(btn.dataset.filter, 10);
                btn.classList.toggle('active', btnStocks === STATE.minStocks);
            });
        }

        function updateDisplayLines() {
            const terminalBody = document.querySelector('.terminal-body');
            const terminalOutput = document.querySelector('.terminal-output');
            
            // 各記事は固定高さ150px
            const articleHeight = 150;
            const padding = 48; // 上下のpadding (1.5rem * 2 * 16px)
            
            const newHeight = articleHeight * STATE.displayLines + padding;
            const minHeight = 100;
            
            terminalBody.style.height = `${Math.max(minHeight, newHeight)}px`;
            
            // 高さ変更後に再計算
            setTimeout(() => {
                STATE.viewHeight = terminalOutput.clientHeight;
                STATE.contentHeight = outputContent.scrollHeight;
            }, 100);
            
            // プリセットボタンのactive状態を更新
            document.querySelectorAll('#lines-dropdown .preset-btn').forEach(btn => {
                const btnLines = parseInt(btn.dataset.lines, 10);
                btn.classList.toggle('active', btnLines === STATE.displayLines);
            });
        }

        // localStorage
        function saveSettings() {
            try {
                localStorage.setItem(STORAGE_KEY, JSON.stringify({
                    speed: STATE.speed,
                    minStocks: STATE.minStocks,
                    displayLines: STATE.displayLines,
                    keyword: STATE.keyword
                }));
            } catch {}
        }

        function loadSettings() {
            try {
                const data = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
                if (typeof data.speed === 'number') STATE.speed = Math.max(0.5, Math.min(10, data.speed));
                if (typeof data.minStocks === 'number') STATE.minStocks = Math.max(0, data.minStocks);
                if (typeof data.displayLines === 'number') STATE.displayLines = Math.max(1, Math.min(5, data.displayLines));
                if (typeof data.keyword === 'string') {
                    STATE.keyword = data.keyword;
                    if (filterKeywordInput) filterKeywordInput.value = data.keyword;
                }
            } catch {}
        }

        // イベント
        speedToggle.addEventListener('click', (e) => {
            e.stopPropagation();
            speedDropdown.classList.toggle('open');
            filterDropdown.classList.remove('open');
            linesDropdown.classList.remove('open');
            speedToggle.classList.toggle('active');
            filterToggle.classList.remove('active');
            linesToggle.classList.remove('active');
        });

        filterToggle.addEventListener('click', (e) => {
            e.stopPropagation();
            filterDropdown.classList.toggle('open');
            speedDropdown.classList.remove('open');
            linesDropdown.classList.remove('open');
            filterToggle.classList.toggle('active');
            speedToggle.classList.remove('active');
            linesToggle.classList.remove('active');
        });

        linesToggle.addEventListener('click', (e) => {
            e.stopPropagation();
            linesDropdown.classList.toggle('open');
            speedDropdown.classList.remove('open');
            filterDropdown.classList.remove('open');
            linesToggle.classList.toggle('active');
            speedToggle.classList.remove('active');
            filterToggle.classList.remove('active');
        });

        document.addEventListener('click', () => {
            speedDropdown.classList.remove('open');
            filterDropdown.classList.remove('open');
            linesDropdown.classList.remove('open');
            speedToggle.classList.remove('active');
            filterToggle.classList.remove('active');
            linesToggle.classList.remove('active');
        });

        [speedDropdown, filterDropdown, linesDropdown].forEach(dd => {
            dd.addEventListener('click', e => e.stopPropagation());
        });

        speedSlider.addEventListener('input', () => {
            STATE.speed = parseFloat(speedSlider.value);
            updateSpeedValue();
            saveSettings();
        });

        speedSlider.addEventListener('change', () => {
            // プルダウンを閉じる
            speedDropdown.classList.remove('open');
            speedToggle.classList.remove('active');
        });

        document.querySelectorAll('#speed-dropdown .preset-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                STATE.speed = parseFloat(btn.dataset.speed);
                updateSpeedValue();
                saveSettings();
                
                // プルダウンを閉じる
                speedDropdown.classList.remove('open');
                speedToggle.classList.remove('active');
            });
        });

        filterSlider.addEventListener('input', () => {
            STATE.minStocks = parseInt(filterSlider.value, 10);
            updateFilterValue();
        });

        filterSlider.addEventListener('change', async () => {
            saveSettings();
            
            // プルダウンを閉じる
            filterDropdown.classList.remove('open');
            filterToggle.classList.remove('active');
            
            // 既存のタイマーをクリア
            if (STATE.displayTimerId) {
                clearTimeout(STATE.displayTimerId);
                STATE.displayTimerId = null;
            }
            STATE.displayPaused = true;
            STATE.canPause = false;
            
            await restartWithCommand();
        });

        if (filterKeywordInput) {
            filterKeywordInput.addEventListener('compositionstart', () => {
                keywordIsComposing = true;
            });

            filterKeywordInput.addEventListener('compositionend', () => {
                keywordIsComposing = false;
                STATE.keyword = filterKeywordInput.value;
            });

            filterKeywordInput.addEventListener('input', () => {
                STATE.keyword = filterKeywordInput.value;
            });

            // Enterで検索(キーワードが空なら従来クエリにフォールバック)
            filterKeywordInput.addEventListener('keydown', async (e) => {
                if (e.key !== 'Enter') return;
                if (e.isComposing || keywordIsComposing) return;
                if (STATE.restarting) return;
                e.preventDefault();
                e.stopPropagation();

                // プルダウンを閉じる
                filterDropdown.classList.remove('open');
                filterToggle.classList.remove('active');

                // 既存のタイマーをクリア
                if (STATE.displayTimerId) {
                    clearTimeout(STATE.displayTimerId);
                    STATE.displayTimerId = null;
                }
                STATE.displayPaused = true; // 既存の表示を停止
                STATE.canPause = false;      // 新しいコマンド完了まで停止禁止

                await restartWithCommand();
            });
        }

        document.querySelectorAll('#filter-dropdown .preset-btn').forEach(btn => {
            btn.addEventListener('click', async () => {
                STATE.minStocks = parseInt(btn.dataset.filter, 10);
                updateFilterValue();
                saveSettings();
                
                // プルダウンを閉じる
                filterDropdown.classList.remove('open');
                filterToggle.classList.remove('active');
                
                // 既存のタイマーをクリア
                if (STATE.displayTimerId) {
                    clearTimeout(STATE.displayTimerId);
                    STATE.displayTimerId = null;
                }
                STATE.displayPaused = true; // 既存の表示を停止
                STATE.canPause = false;      // 新しいコマンド完了まで停止禁止
                
                // 現在の表示をクリアしてコマンドから再開
                await restartWithCommand();
            });
        });

        document.querySelectorAll('#lines-dropdown .preset-btn').forEach(btn => {
            btn.addEventListener('click', () => {
                STATE.displayLines = parseInt(btn.dataset.lines, 10);
                updateDisplayLines();
                saveSettings();
                
                // プルダウンを閉じる
                linesDropdown.classList.remove('open');
                linesToggle.classList.remove('active');
            });
        });

        // コントローラー表示制御
        const terminalWindow = document.querySelector('.terminal-window');
        const terminalControls = document.querySelector('.terminal-controls');
        let controlsHideTimer = null;
        const terminalOutput = document.querySelector('.terminal-output');

        function showControls() {
            if (terminalControls) {
                terminalControls.classList.add('show');
            }
            if (terminalOutput) {
                terminalOutput.classList.add('scrolling');
            }
            clearTimeout(controlsHideTimer);
        }

        function scheduleHideControls() {
            clearTimeout(controlsHideTimer);
            controlsHideTimer = setTimeout(() => {
                if (terminalControls) {
                    terminalControls.classList.remove('show');
                }
                if (terminalOutput) {
                    terminalOutput.classList.remove('scrolling');
                }
            }, 2000);
        }

        if (terminalWindow) {
            terminalWindow.addEventListener('mouseenter', showControls);
            terminalWindow.addEventListener('mouseleave', scheduleHideControls);
        }

        // ユーザーの手動スクロール操作(マウスホイール、タッチスワイプ)のみで表示
        if (terminalOutput) {
            // マウスホイール
            terminalOutput.addEventListener('wheel', () => {
                showControls();
                scheduleHideControls();
            }, { passive: true });

            // タッチスワイプ
            terminalOutput.addEventListener('touchmove', () => {
                showControls();
                scheduleHideControls();
            }, { passive: true });
        }

        // 共通ローディング表示
        function showLoadingLine() {
            const loadingLine = document.createElement('div');
            loadingLine.className = 'terminal-line';
            loadingLine.style.color = 'var(--color-text-dim)';
            loadingLine.textContent = '.';
            outputContent.appendChild(loadingLine);
            let dotCount = 1;
            const loadingInterval = setInterval(() => {
                dotCount = (dotCount % 3) + 1;
                loadingLine.textContent = '.'.repeat(dotCount);
            }, 400);
            return () => {
                clearInterval(loadingInterval);
                loadingLine.remove();
            };
        }

        // 共通の選択肢UI
        function showChoicesUI({ onNext, onQuit, marginTop = '1.5rem' }) {
            outputContent.innerHTML += `<div class="terminal-line article-meta-line" style="margin-top: ${marginTop};"></div>`;
            
            // 質問プロンプト追加
            const promptLine = document.createElement('div');
            promptLine.className = 'terminal-line choice-prompt';
            promptLine.textContent = 'What would you like to do?';
            outputContent.appendChild(promptLine);
            
            const choiceLine = document.createElement('div');
            choiceLine.className = 'choice-line';
            choiceLine.innerHTML = `
<div class="choice-item active" data-choice="next" tabindex="0">
  <span class="choice-cursor">></span>
  <span class="choice-number">1)</span>
  <span class="choice-label">next 50件を見る</span>
</div>
<div class="choice-item" data-choice="quit" tabindex="0">
  <span class="choice-cursor">></span>
  <span class="choice-number">2)</span>
  <span class="choice-label">quit 終了</span>
</div>`;
            outputContent.appendChild(choiceLine);
            const terminalOutput = document.querySelector('.terminal-output');
            terminalOutput.scrollTop = terminalOutput.scrollHeight;

            const choiceItems = choiceLine.querySelectorAll('.choice-item');
            let activeIndex = 0;

            const updateSelection = () => {
                choiceItems.forEach((item, idx) => {
                    item.classList.toggle('active', idx === activeIndex);
                });
            };

            const cleanup = () => {
                document.removeEventListener('keydown', handleKey);
            };

            const handleChoice = async (choice) => {
                cleanup();
                choiceLine.remove();
                if (choice === 'next') {
                    await onNext();
                } else {
                    await onQuit();
                }
            };

            const handleKey = (e) => {
                if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
                    e.preventDefault();
                    choiceItems[activeIndex].classList.remove('active');
                    if (e.key === 'ArrowDown') {
                        activeIndex = (activeIndex + 1) % choiceItems.length;
                    } else {
                        activeIndex = (activeIndex - 1 + choiceItems.length) % choiceItems.length;
                    }
                    updateSelection();
                } else if (e.key === 'Enter') {
                    e.preventDefault();
                    handleChoice(choiceItems[activeIndex].dataset.choice);
                } else if (e.key === '1') {
                    e.preventDefault();
                    handleChoice('next');
                } else if (e.key === '2') {
                    e.preventDefault();
                    handleChoice('quit');
                }
            };

            document.addEventListener('keydown', handleKey);
            updateSelection();

            choiceItems.forEach((item, index) => {
                item.addEventListener('click', () => {
                    handleChoice(item.dataset.choice);
                });
                item.addEventListener('mouseenter', () => {
                    activeIndex = index;
                    updateSelection();
                });
            });
        }

        // 共通表示コントローラ
        function startDisplay({ articles, showChoices = true, choiceMarginTop = '1.5rem', scrollFirstToTop = false }) {
            if (!articles || !articles.length) {
                outputContent.innerHTML += '<div class="error-message">記事が見つかりません</div>';
                return;
            }

            // リセット
            STATE.articles = articles;
            STATE.displayPaused = false;
            STATE.canPause = false;
            STATE.currentArticleIndex = 0;
            if (STATE.displayTimerId) {
                clearTimeout(STATE.displayTimerId);
                STATE.displayTimerId = null;
            }

            const terminalOutput = document.querySelector('.terminal-output');

            const displayNextArticle = () => {
                if (STATE.displayPaused || STATE.currentArticleIndex >= STATE.articles.length) return;

                const article = STATE.articles[STATE.currentArticleIndex];

                const articleEl = document.createElement('div');
                articleEl.className = 'article-item';
                articleEl.innerHTML = buildArticleLinesHTML(article);
                outputContent.appendChild(articleEl);
                const latestArticle = articleEl;

                // 追加をスムーズに見せる(ログ感は維持)
                requestAnimationFrame(() => {
                    articleEl.classList.add('is-entering');
                });
                articleEl.addEventListener('animationend', () => {
                    articleEl.classList.remove('is-entering');
                }, { once: true });

                // 記事が1件でも表示されたら停止を許可
                STATE.canPause = true;

                // 初回記事はオプションで先頭揃え。それ以降は従来通り最下部へ。
                if (latestArticle && scrollFirstToTop && STATE.currentArticleIndex === 0) {
                    scrollElementToTopInContainer(terminalOutput, latestArticle);
                } else if (STATE.currentArticleIndex > 0 && terminalOutput) {
                    // ターミナル内だけ、なめらかに追従
                    try {
                        terminalOutput.scrollTo({ top: terminalOutput.scrollHeight, behavior: 'smooth' });
                    } catch {
                        terminalOutput.scrollTop = terminalOutput.scrollHeight;
                    }
                }

                STATE.currentArticleIndex++;

                // 終端: 選択肢を表示
                if (STATE.currentArticleIndex >= STATE.articles.length) {
                    if (showChoices) {
                        STATE.displayTimerId = setTimeout(() => {
                            showChoicesUI({
                                onNext: async () => {
                                    STATE.currentPage++;
                                    const cleanup = showLoadingLine();
                                    const nextArticles = await fetchArticles(STATE.currentPage);
                                    cleanup();
                                    outputContent.innerHTML += '<div class="terminal-line article-meta-line"></div>';
                                    if (nextArticles && nextArticles.length > 0) {
                                        startDisplay({ articles: nextArticles, showChoices: true, choiceMarginTop });
                                    } else {
                                        outputContent.innerHTML += '<div class="terminal-line" style="color: var(--color-warning);">No more articles found.</div>';
                                    }
                                },
                                onQuit: async () => {
                                    STATE.canPause = false; // quit後はpauseハンドラを無効化
                                    const quitMsg = `<div class="terminal-line" style="margin-top: 2rem; color: var(--color-success);">
Thanks for reading!</div>
<div class="terminal-line" style="color: var(--color-text-dim); margin-top: 0.5rem;">
Press R to reload | Press any key to stay</div>`;
                                    outputContent.innerHTML += quitMsg;
                                    terminalOutput.scrollTop = terminalOutput.scrollHeight;
                                    const handleQuitKey = (e) => {
                                        if (e.key === 'r' || e.key === 'R') {
                                            location.reload();
                                        }
                                        document.removeEventListener('keydown', handleQuitKey);
                                    };
                                    document.addEventListener('keydown', handleQuitKey);
                                },
                                marginTop: choiceMarginTop
                            });
                        }, 500);
                    }
                    return;
                }

                // 次の記事をスピードに応じた遅延で表示
                const baseDelay = 6000; // 6秒(標準速度)
                const delay = baseDelay / STATE.speed;
                if (!STATE.displayPaused) {
                    STATE.displayTimerId = setTimeout(displayNextArticle, delay);
                }
            };

            currentDisplayLoop = { displayNextArticle };
            displayNextArticle();
        }

        // コマンドから再開する関数
        async function restartWithCommand() {
            if (STATE.restarting) return;
            STATE.restarting = true;
            try {
            // 前の記事はそのまま残す(クリアしない)
            
            // 停止メッセージが残っている場合は削除
            const lines = outputContent.querySelectorAll('.terminal-line');
            const lastLine = lines[lines.length - 1];
            if (lastLine && lastLine.textContent.includes('続行するには')) {
                lastLine.remove();
            }
            
            // 表示制御をリセット
            STATE.displayPaused = false;
            STATE.currentArticleIndex = 0;
            STATE.canPause = false;
            if (STATE.displayTimerId) {
                clearTimeout(STATE.displayTimerId);
                STATE.displayTimerId = null;
            }
            
            // 空行を追加してから新しいコマンド表示(余白はクラス側に任せる)
            outputContent.innerHTML += '<div class="terminal-line article-meta-line"></div>';
            
            // コマンド表示
            const hasKeyword = typeof STATE.keyword === 'string' && STATE.keyword.trim() !== '';
            const keywordArg = hasKeyword ? ` --keyword=${STATE.keyword}` : '';
            const stocksArg = STATE.minStocks > 0 ? ` --stocks=${STATE.minStocks}` : '';
            const commandText = `qiita${keywordArg}${stocksArg}`;
            const commandTextEscaped = escapeHtml(commandText);
            
            // タイピングアニメーション
            const typingLine = document.createElement('div');
            typingLine.className = 'terminal-line';
            const typingDurationMs = getTypingDurationMs(commandText);
            const typingDurationSec = (typingDurationMs / 1000).toFixed(3);
            typingLine.innerHTML = `<span class="prompt">$</span> <span class="typing-command command" style="--typing-chars:${commandText.length}ch; animation: typing ${typingDurationSec}s steps(${commandText.length}) forwards, blink-caret 0.75s step-end infinite;">${commandTextEscaped}</span>`;
            outputContent.appendChild(typingLine);
            
            // コマンド行追加後、記事と同様の位置に合わせて表示
            const terminalOutput = document.querySelector('.terminal-output');
            if (terminalOutput) {
                // レイアウト確定後にターミナル内だけスクロール(ページ全体は動かさない)
                requestAnimationFrame(() => {
                    scrollElementToTopInContainer(terminalOutput, typingLine);
                });
            }
            
            // タイピングアニメーション完了を待つ
            await new Promise(resolve => setTimeout(resolve, typingDurationMs + 100));
            
            // コマンド実行完了(カーソル削除)
            typingLine.innerHTML = `<span class="prompt">$</span> <span class="command">${commandTextEscaped}</span>`;
            
            // ローディング表示
            const cleanupLoading = showLoadingLine();
            
            // 記事取得
            const articles = await fetchArticles();
            
            // ローディング表示を削除して空行を追加
            cleanupLoading();
            outputContent.innerHTML += '<div class="terminal-line article-meta-line"></div>';
            
            if (articles && articles.length > 0) {
                STATE.currentPage = 1;
                startDisplay({ articles, showChoices: true, choiceMarginTop: '1.5rem', scrollFirstToTop: true });
            } else {
                outputContent.innerHTML += '<div class="error-message">記事が見つかりません</div>';
            }
            } finally {
                STATE.restarting = false;
            }
        }
        
        // 一時停止メッセージを削除
        function removePauseMessage() {
            const lines = outputContent.querySelectorAll('.terminal-line');
            const lastLine = lines[lines.length - 1];
            if (lastLine && lastLine.textContent.includes('続行するには')) {
                lastLine.remove();
            }
        }

        // 表示を一時停止
        function pauseDisplay() {
            if (STATE.displayPaused || !STATE.canPause || STATE.currentArticleIndex >= STATE.articles.length) return;
            STATE.displayPaused = true;
            if (STATE.displayTimerId) {
                clearTimeout(STATE.displayTimerId);
                STATE.displayTimerId = null;
            }
            outputContent.innerHTML += '<div class="terminal-line" style="margin-top: 1rem; color: var(--color-warning);">続行するには再度クリック、または何かキーを押してください...</div>';
            const terminalOutput = document.querySelector('.terminal-output');
            terminalOutput.scrollTop = terminalOutput.scrollHeight;
        }

        // 表示を再開
        function resumeDisplay() {
            if (!STATE.displayPaused) return;
            STATE.displayPaused = false;
            removePauseMessage();
            if (currentDisplayLoop && typeof currentDisplayLoop.displayNextArticle === 'function') {
                currentDisplayLoop.displayNextArticle();
            }
        }

        // 停止/再開ハンドラをセットアップ
        let pauseHandlersInitialized = false;
        function setupPauseHandlers() {
            if (pauseHandlersInitialized) return;
            const terminalOutput = document.querySelector('.terminal-output');
            if (!terminalOutput) return;

            terminalOutput.addEventListener('click', () => {
                if (!STATE.canPause) return;
                if (!STATE.displayPaused && STATE.currentArticleIndex < STATE.articles.length) {
                    pauseDisplay();
                } else if (STATE.displayPaused) {
                    resumeDisplay();
                }
            });

            // スクロールで中断
            terminalOutput.addEventListener('wheel', () => {
                if (!STATE.canPause) return;
                if (!STATE.displayPaused && STATE.currentArticleIndex < STATE.articles.length) {
                    pauseDisplay();
                }
            }, { passive: true });

            // キー押下で中断または再開
            document.addEventListener('keydown', (e) => {
                // 入力欄でのキー入力は無視
                if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
                if (!STATE.canPause) return;
                if (!STATE.displayPaused && STATE.currentArticleIndex < STATE.articles.length) {
                    pauseDisplay();
                } else if (STATE.displayPaused) {
                    resumeDisplay();
                }
            });

            pauseHandlersInitialized = true;
        }

        // 初期化
        async function init() {
            try {
                loadSettings();

                updateSpeedValue();
                updateFilterValue();
                updateDisplayLines();
                setupPauseHandlers();

                // コマンド文字列(フィルター反映)
                const hasKeywordInit = typeof STATE.keyword === 'string' && STATE.keyword.trim() !== '';
                const keywordArgInit = hasKeywordInit ? ` --keyword=${STATE.keyword}` : '';
                const stocksArgInit = STATE.minStocks > 0 ? ` --stocks=${STATE.minStocks}` : '';
                const commandTextInit = `qiita${keywordArgInit}${stocksArgInit}`;
                const commandTextInitEscaped = escapeHtml(commandTextInit);

                const typingDurationMsInit = getTypingDurationMs(commandTextInit);
                const typingDurationSecInit = (typingDurationMsInit / 1000).toFixed(3);

                // タイピングアニメーション: $ qiita...(フィルター込み)
                outputContent.innerHTML = `<div class="terminal-line"><span class="prompt">$</span> <span class="typing-command command" style="--typing-chars:${commandTextInit.length}ch; animation: typing ${typingDurationSecInit}s steps(${commandTextInit.length}) forwards, blink-caret 0.75s step-end infinite;">${commandTextInitEscaped}</span></div>`;

                // タイピングアニメーション完了を待つ
                await new Promise(resolve => setTimeout(resolve, typingDurationMsInit + 100));

                // コマンド実行完了(カーソル削除)
                outputContent.innerHTML = `<div class="terminal-line"><span class="prompt">$</span> <span class="command">${commandTextInitEscaped}</span></div>`;

                // ローディング表示
                const cleanupLoading = showLoadingLine();

                // 記事取得
                const articles = await fetchArticles();

                // ローディング表示を削除して空行を追加
                cleanupLoading();
                outputContent.innerHTML += '<div class="terminal-line article-meta-line"></div>';

                if (articles && articles.length > 0) {
                    STATE.currentPage = 1;
                    startDisplay({ articles, showChoices: true, choiceMarginTop: '1.5rem' });
                } else {
                    outputContent.innerHTML += '<div class="error-message">記事が見つかりません</div>';
                }
            } catch (error) {
                console.error('初期化エラー:', error);
                outputContent.innerHTML = '<div class="error-message">エラーが発生しました</div>';
            }
        }

        init();

このティッカーの設計思想

このティッカーは、単なる記事リストではなく、「ターミナルでコマンドを実行している」という体験を提供することを重視しています。黒背景に緑文字という配色は、Unix系OSのターミナルを彷彿とさせ、技術者に親しみやすいデザインです。

また、記事が自動的に流れていく「ティッカー」の特性を活かしながら、ユーザーが必要に応じて一時停止したり、フィルター条件を変更したりできるインタラクティブ性を持たせています。これにより、背景で情報を流し続けつつ、気になる記事があれば立ち止まって読めるという、能動と受動の両方の使い方ができます。

localStorageを使ってユーザーの設定を保存しているため、ページを再読み込みしても前回の設定が維持されます。このような細かい配慮が、実用的なWebパーツには不可欠です。

AIへのプロンプト例

以下のようなプロンプトをAIに送信します:

Qiita APIを使って、最新のAI関連記事を取得してターミナル風に表示するティッカーを作成してください。

### 初期表示
- 黒背景のターミナル風デザイン
- 緑色の文字でQiita風の配色
- コマンドラインのタイピングアニメーション
- 記事が自動的に流れるように表示

### 機能要件
- Qiita APIから記事を取得
- タイトル、作者、いいね数、ストック数を表示
- キーワード検索機能
- ストック数でのフィルター機能
- 表示速度の調整機能
- 表示行数の変更機能

### インタラクション
- ホバーでコントロールパネル表示
- クリックで一時停止・再開
- 記事のリンククリックで新規タブで開く

このティッカーの特徴

このティッカーが持つ主な特徴を見ていきましょう。それぞれの機能が、実用性とユーザー体験の向上に貢献しています。

  • リアルタイムAPI連携 Qiita APIから最新記事を自動取得し、常に新鮮な情報を提供
  • ターミナル風UI 黒背景に緑文字のレトロなデザインで、開発者に親しみやすい見た目
  • タイピング演出 コマンドがタイピングアニメーションで表示され、本物のターミナルのような臨場感
  • 柔軟なフィルター キーワードとストック数で記事を絞り込み、目的の情報にアクセスしやすい
  • 速度調整 0.5倍から10倍まで表示速度を変更可能で、用途に応じた使い分けが可能
  • 一時停止機能 クリックやスクロールで表示を一時停止し、気になる記事をじっくり確認
  • 設定の永続化 localStorageでユーザーの設定を保存し、次回訪問時も同じ環境で使用可能
  • レスポンシブ対応 PCでもスマホでも快適に閲覧可能なデザイン

コードのポイント

1. API連携とデータ取得

外部サービスのデータを取得する際は、非同期処理とエラーハンドリングが重要です。このティッカーでは、JavaScriptのfetch関数を使ってQiita APIにアクセスし、記事データを取得しています。

async function fetchArticles(page = 1) {
    const hasKeyword = typeof STATE.keyword === 'string' && STATE.keyword.trim() !== '';
    let query = hasKeyword ? STATE.keyword.trim() : CONFIG.QUERY_PARAMS.query;
    if (STATE.minStocks > 0) query += ` stocks:>${STATE.minStocks}`;
    
    const url = `${CONFIG.API_URL}?query=${encodeURIComponent(query)}&per_page=${CONFIG.QUERY_PARAMS.per_page}&page=${page}`;
    const response = await fetch(url);
    const items = await response.json();
    
    return items.map(item => ({
        title: item.title || '',
        url: item.url || '',
        author: item.user?.id || '',
        likes: item.likes_count || 0,
        stocks: item.stocks_count || 0,
        createdAt: item.created_at || ''
    }));
}

async/await構文を使うことで、APIレスポンスを待ってから次の処理に進めます。URLパラメータとして検索キーワードとストック数の条件を組み込むことで、動的なフィルタリングを実現しています。取得したJSONデータから必要な項目(タイトル、URL、いいね数、ストック数など)だけを抽出し、表示用のデータ構造に整形することで、処理効率を高めています。

2. タイピングアニメーション:
.typing-command {
    overflow: hidden;
    border-right: 2px solid var(--color-qiita-green);
    white-space: nowrap;
    animation: typing 1.5s steps(5) forwards, blink-caret 0.75s step-end infinite;
}

@keyframes typing {
    from { width: 0; }
    to { width: var(--typing-chars, 5ch); }
}

@keyframes blink-caret {
    50% { border-color: transparent; }
}
  • steps()関数でタイプライター風の段階的アニメーション
  • カーソルの点滅効果で本物のターミナルのような演出
  • CSS変数で文字数に応じた幅を動的に設定
3. 速度調整機能

ユーザーの読む速度に合わせて表示タイミングを調整できる機能は、ティッカーの使い勝手を大きく向上させます。速度調整は単純な計算式で実現されています。

// 次の記事をスピードに応じた遅延で表示
const baseDelay = 6000; // 6秒(標準速度)
const delay = baseDelay / STATE.speed;

if (!STATE.displayPaused) {
    STATE.displayTimerId = setTimeout(displayNextArticle, delay);
}

基準となる遅延時間(6秒)をユーザーが設定した速度値で割ることで、動的に表示間隔を計算しています。例えば、速度を2倍に設定すれば3秒間隔、0.5倍なら12秒間隔になります。この仕組みにより、0.5倍から10倍まで幅広い速度調整が可能になっています。

setTimeoutを使って次の記事表示をスケジュールすることで、ブラウザのメインスレッドをブロックせず、スムーズな動作を実現しています。タイマーIDをSTATEオブジェクトに保存することで、後から一時停止や再開ができるようになっています。

4. 一時停止・再開機能

ティッカーは常に記事が流れ続けますが、ユーザーが興味を持った記事を読みたい時や、設定を変更したい時には一時停止できることが重要です。この機能により、能動的な情報収集と受動的な情報流入の両立が可能になります。

function pauseDisplay() {
    if (STATE.displayPaused || !STATE.canPause) return;
    STATE.displayPaused = true;
    
    if (STATE.displayTimerId) {
        clearTimeout(STATE.displayTimerId);
        STATE.displayTimerId = null;
    }
    
    outputContent.innerHTML += '<div>続行するには再度クリック...</div>';
}

function resumeDisplay() {
    if (!STATE.displayPaused) return;
    STATE.displayPaused = false;
    removePauseMessage();
    
    if (currentDisplayLoop) {
        currentDisplayLoop.displayNextArticle();
    }
}

クリック、スクロール、キー押下などのユーザー操作を検知して一時停止します。clearTimeoutで予定されていた次の記事表示をキャンセルし、STATE.displayPausedフラグで一時停止状態を記録します。再開時は、停止した位置から続きを表示するため、ユーザーは見逃すことがありません。

一時停止中であることを視覚的に伝えるメッセージを表示することで、ユーザーは現在の状態を把握しやすくなります。このような細かいフィードバックが、使いやすいUIには不可欠です。

このティッカーは本格的なWebアプリケーションです。API連携、状態管理、アニメーション制御など、実務で使われる技術が詰まっています。コードは長いですが、各機能がモジュール化されているので、必要な部分だけ抜き出して使うこともできますよ。

まとめ

男子生徒のアイコン

すごい!本物のターミナルみたいに動いてる!記事が流れていくところとか、クリックで止められるところも、全部スムーズですね。

AI先生のアイコン

そうだね。APIからデータを取得して、タイマーで表示を制御し、ユーザー操作に反応するという、複数の技術が組み合わさっているんだ。特にタイマーの管理は重要で、setTimeoutclearTimeoutを適切に使うことで、ユーザーがストレスなく操作できるようになっているよ。

女子生徒のアイコン

Qiita APIって誰でも使えるんですか?ブログとかに組み込んでみたいです。

AI先生のアイコン

もちろん。Qiita APIは公開されていて、認証なしでも基本的な検索はできるんだ。ただ、リクエストの頻度には制限があるから、短時間に大量のリクエストを送らないよう注意が必要だよ。このティッカーでは6秒間隔を基準にしているのも、そういった配慮からなんだ。

男子生徒のアイコン

状態管理って、つまりSTATEオブジェクトに設定をまとめておくってことですか?

AI先生のアイコン

その通り。アプリケーションの設定や状態を1箇所に集約することで、コードの見通しが良くなるし、バグも減らせるんだ。今回は速度、フィルター条件、一時停止状態などをSTATEにまとめているから、どの関数からでも簡単にアクセスできるようになっている。ReactやVueなどのフレームワークでも、こういった状態管理の考え方は基本なんだよ。

女子生徒のアイコン

localStorageで設定を保存しているのも、ユーザー目線ですごくいいですね!

AI先生のアイコン

そうなんだ。ユーザーが一度設定した内容を次回以降も保持することで、使い勝手が大きく向上する。ただ、localStorageはドメインごとに分かれているから、同じドメインで複数のパーツを使う場合は、キー名が重複しないように注意が必要だよ。今回は「pa-tu:qiita-terminal:v1」というユニークなキー名を使っているね。

Qiitaティッカー活用のポイント
  • API連携の基本 fetchを使った非同期データ取得とエラーハンドリング。外部APIとの連携では、リクエスト制限やタイムアウトに配慮が必要
  • 状態管理 STATEオブジェクトでアプリケーションの状態を一元管理。設定値、フラグ、タイマーIDなどを1箇所に集約することで保守性向上
  • タイミング制御 setTimeoutとclearTimeoutで表示タイミングを制御。一時停止・再開機能では、実行中のタイマーを適切に解除
  • ユーザー操作 クリック、スクロール、キー入力などのイベントハンドリング。ユーザーが期待するタイミングで反応することが重要
  • localStorageの活用 ユーザーの設定を保存して次回訪問時に復元。キー名の衝突を避けるため、ユニークな接頭辞を付ける
  • レスポンシブ対応 メディアクエリでPC/スマホそれぞれに最適化。タッチデバイスでも快適に操作できるよう配慮
  • アニメーション演出 CSSとJavaScriptを組み合わせた滑らかな表示効果。steps()関数でタイプライター風の段階的アニメーションを実現

このティッカーのようなデータ表示パーツは、ブログやポートフォリオサイトで活用できます。API元を変えれば、Twitter(X)やGitHub、Zenn、noteなど、他のサービスにも応用可能ですよ!

学習チェック

このレッスンを理解できたら「完了」をクリックしてください。
後で見直したい場合は「未完了に戻す」で進捗をリセットできます。

レッスン完了!🎉

お疲れさまでした!