JavaScriptの部屋 週刊アスキー連載のプログラム紹介です。

JavaScriptの部屋

週刊アスキー連載のプログラム紹介です。自分で誌面のプログラムを打ち込んでHTMLファイルを作ります。そのHTMLをブラウザで開いて動かすことができます。
打ち込みに時間がかかりますが、打ち込みながらJavaScriptやCSSなどが学べます。
今回は誌面を画像として保存しプログラムをテキスト抽出してエディターで編集してHTMLファイルを作ってみました。
ジャパンデーターサーチや国土地理院のAPIを使った二つのプログラムを紹介します。
著作権は週刊アスキーにあります。

文化財になっている建造物を検索、表示する

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>文化財建造物マップ</title>
<script>
let canvas, context;// キャンバス
let [lat, lng] = [35.6581, 139.74135];// 基準点の緯度、経度
let tx, ty, z, qz = 0;// 中心のタイル座標、ズームレベル
let points = new Array();// 建造物の緯度、経度、マーク
// 国土地理院 API、ジャパンサーチ API
const gsi_url = "https://cyberjapandata.gsi.go.jp/xyz"; //国土地理院タイルのURL
const jpsearch_url = "https://jpsearch.go.jp/api/item/search/jps-cross";//ジャパンサーチ
const param = "?f-type=architecture&f-cm=cultural";

const init = () => {
    // IMG 要素、CANVAS 要素の作成
    for (let x = 0; x < 3; x++) { //地図画像用3×3
        for (let y = 0; y < 3; y++) {
            const img = document.createElement("img");//画像を作成
            img.id = `map_${x}_${y}`;
            img.style.top = `${256 * y}px`;//表示座標をCSSでセット
            img.style.left = `${256 * x}px`;
            document.getElementById("map").appendChild(img);//地図エリア(157行)に挿入
        }
    }
    canvas = document.createElement("canvas");//キャンバスを作成(円マーク表示用)
    [canvas.width, canvas.height] = [256 * 3, 256 * 3];//キャンバスサイズ768
    context = canvas.getContext("2d");
    document.getElementById("map").appendChild(canvas);
    // ズームイン/アウト
    document.getElementById("map").addEventListener("wheel", event => {//マウスホール回転のとき
        event.preventDefault();//画面のスクロールをキャンセル
        if (event.deltaY < 0) document.getElementById("z").value++;//ズームスライダーを増減させる
        if (event.deltaY > 0) document.getElementById("z").value--;
        showMap();
    });
    // 基準点をセット
    document.getElementById("map").addEventListener("click", event => {//地図をクリックしたとき
        const x = (tx - 1 + event.offsetX / 256) / 2 ** z;//クリック場所のズームに応じた緯度経度を計算
        const y = (ty - 1 + event.offsetY / 256) / 2 ** z;
        lng = 360 * x - 180;
        lat = 2 * Math.atan(Math.exp(Math.PI - 2 * Math.PI * y)) * 180 / Math.PI - 90;
        qz = 0;
        showMap();
    });
    showMap();//地図やマークを描画更新
}
const showMap = () => {
    // 地図タイルを表示
    z = document.getElementById("z").value;//ズームスライダーの値を取得
    const [x, y] = getPosition(lat, lng);//緯度経度を座標に変換
    [tx, ty] = [Math.floor(x / 256), Math.floor(y / 256)];//中心タイルの座標
    for (let i = 0; i < 3; i++) {
        for (let j = 0; j < 3; j++) {
            const img = document.getElementById(`map_${i}_${j}`);//現在の地図画像を取得
            img.src = `${gsi_url}/std/${z}/${tx + i - 1}/${ty + j - 1}.png`;//新しいマップを国土地理院から取得してセット
        }
    }
    // マークの描画
    context.clearRect(0, 0, canvas.width, canvas.height);
    const [cx, cy] = [256 + x % 256, 256 + y % 256];//マークの中心座標
    drawMark(cx, cy, 10, "#0000FF");//半径10 青色で円を描画
    if (qz > 0) drawMark(cx, cy, 256 * 2 ** (z - qz), "#0000FF", false);//検索範囲の円を描画
    points.sort((a, b) => a.mark - b.mark);//赤い円が上になるよう見つかった建造物を並べ替える
    points.forEach(p => {
        let [x, y] = getPosition(p.lat, p.lng);//建造物の座標を計算
        x = (Math.floor(x / 256) - tx + 1) * 256 + x % 256;
        y = (Math.floor(y / 256) - ty + 1) * 256 + y % 256;
        let color = "#A000FF";//詳細表示中なら赤
        if (p.mark) color = "#FF0000";//詳細表示中でないなら紫
        drawMark(x, y, 20, color);//半径20の円を描画
    });
}

const getPosition = (lat, lng) => {//緯度:lat 経度:lng
    // 緯度経度を座標に変換
    const r = 256 / (Math.PI * 2);//ズーム時の地球の半径
    const [rLat, rLng] = [lat * Math.PI / 180, lng * Math.PI / 180];//緯度経度をラジアンに変換
    const x = (256 / 2 + r * rLng) * 2 ** z;//緯度経度をxy座標にしてズームに応じて拡大
    const y = (256 / 2 - r * Math.log(Math.tan(Math.PI / 4 + rLat / 2))) * 2 ** z;
    return [x, y];
};

const drawMark = (x, y, r, color, fill = true) => {
    // マークの描画
    context.strokeStyle = color;//描画線の色
    context.fillStyle = "rgba(100, 100, 255, 0.3)";//塗りつぶし色 半透明
    context.lineWidth = 5;//線の太さ
    context.beginPath();
    context.arc(x, y, r, 0, Math.PI * 2);//円(パス)を描く
    if (fill) context.fill();//因数fillがtrueなら半透明青で塗りつぶす
    context.stroke();
}

const query = async () => {
    // 文化財建造物検索
    let size = document.getElementById("size").value;//最大件数171行の値を取得
    size = Math.min(Math.max(size, 20), 500);//範囲を20から500の間に限定
    document.getElementById("size").value = size;
    const r = 111 * Math.cos(lat * Math.PI / 180) * 360 / 2 ** z;//検索範囲の半径を計算
    const aParam = `&size=${size}&g-coordinates=${lat},${lng},${r}km`;//パラメーターをセット
    const response = await fetch(jpsearch_url + param + aParam);//ジャパンサーチからデーター取得
    const data = await response.json();//JSONオブジェクトに変換
    document.getElementById("list").innerHTML = "";
    points = [];
    data.list.forEach(item => {
        // タイトル、説明文を表示
        const building = document.createElement("details");//details要素を取得
        const title = document.createElement("summary");//summory要素を取得
        title.innerText = item.common.title;//建物名を表示
        building.appendChild(title);
        const desc = document.createElement("div");//div要素を取得
        desc.innerText = item.common.description;//説明があるならセット
        if (item. common. description == undefined) desc. innerText = "";
        building.appendChild(desc);
        document.getElementById("list").appendChild(building);//建造物一覧176行に追加
        // 緯度、経度を格納
        const pLat = item.common.coordinates.lat;//建造物の緯度経度マークとフラグを配列pointsに追加
        const pLng = item.common.coordinates.lon;
        const point = { lat: pLat, lng: pLng, mark: false };
        points.push(point);
        // マークの切替
        building.addEventListener("toggle", () => {//details要素の状態が変わったとき
            point.mark = building.open;//状態(詳細表示中ならtrue閉じているときfalse)をマークフラグにセット
            showMap();
        });
    });
    qz = 2;//検索時のズーム値を保存
    showMap();
}
</script>
<style>
#wrapper {display: flex;}
#map {
    position: relative;
    width: 768px;
    height: 768px;
    margin-right: 10px;
}
img {
    position: absolute;
    width: 256px;
    height: 256px;
}
canvas {
    position: absolute;
    border: thin solid #CCCCCC;
}
#list {
    width: 250px;
    max-height: 768px;
    overflow: auto;
}
summary {
    color: #FFFFFF;
    padding: 1px 2px;
    background-color: #336633;
    border: thin solid #CCCCC;
}
details div {
    font-size: small;
    padding: 2px 5px;
    background-color: #EEFFEE;
}
</style>
</head>
<body onload="init()">
<p>文化財建造物マップ</p>
ズーム: <input type="range" id="z" value="12" min="5" max="18" onchange="showMap()">
最大件数: <input type="number" id="size" value="20" min="20" max="500" step="10">
<input type="button" value="検索" onclick="query()">
<hr>
<div id="wrapper">
<div id="map"></div>
<div id="list"></div>
</div>
</body>
</html>

我が国にある多様な絵画・版画を鑑賞しよう

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>絵画アーカイブ検索</title>
<link href="https://unpkg.com/gridjs/dist/theme/mermaid.min.css" rel="stylesheet" />
<script src="https://unpkg.com/gridjs/dist/gridjs.umd.js"></script>
<script>
let grid; // グリッド表
const api = "https://jpsearch.go.jp/api/item/search/jps-cross?f-type=paint";

const init = () => {
    grid = new gridjs.Grid({
        columns: [
            {name: "タイトル", sort: true},
            {name: "作者", sort: true},
            {name: "時代", sort: true},
            {name: "画像 URL", hidden: true},
            {name: "説明", hidden: true},
            {
                name: "詳細",
                width: "100px",
                formatter: (cell, row) => {
                    return gridjs.h("button", {
                        onClick: () => showData(row)
                    }, "開く");
                }
            }
        ],
        search: true,
        pagination: {limit: 5},
        resizable: true,
        data: [],
        language: {
            search: {placeholder: "絞り込みキーワード"},
            pagination: {
                previous: "前",
                next: "次",
                showing: " ",
                to: "~",
                of: "件目を表示(全",
                results: "件)"
            },
            loading: "検索中・・・",
            noRecordsFound: "検索キーワードを入力してください。",
            error: "情報が取得できませんでした。"
        }
    }).render(document.getElementById("table"));
};

const query = () => {
    const keyword = document.getElementById("keyword").value;
    let size = document.getElementById("size").value;
    size = Math.min(Math.max(size, 20), 500);
    document.getElementById("size").value = size;

    if (keyword != "") {
        const url = `${api}&keyword=${keyword}&size=${size}`;
        grid.updateConfig({
            server: {
                url: url,
                then: data => data.list.map(item => [
                    item.common.title,
                    arrayToText(item.common.contributor),
                    arrayToText(item.common.temporal),
                    item.common.contentsURI,
                    item.common.description,
                    null
                ])
            }
        }).forceRender();
    } else {
        alert("検索キーワードを入力してください。");
    }
};

const arrayToText = array => {
    let text = "";
    if (array != undefined) {
        if (Array.isArray(array)) {
            text = array.join("/");
        } else {
            text = array;
        }
    }
    return text;
};

const showData = row => {
    document.getElementById("title").innerText = row.cells[0].data;
    document.getElementById("contributor").innerText = row.cells[1].data;
    document.getElementById("temporal").innerText = row.cells[2].data;

    document.getElementById("image").innerHTML = "";
    if (row.cells[3].data != undefined) {
        const img = document.createElement("img");
        img.src = row.cells[3].data;
        img.onerror = () => {
            document.getElementById("image").innerHTML = "";
        };
        document.getElementById("image").appendChild(img);
    }

    let desc = "";
    if (row.cells[4].data != undefined) {
        desc = row.cells[4].data.replaceAll("\n", "<br>");
    }
    document.getElementById("description").innerHTML = desc;
    document.getElementById("dialog").showModal();
    document.getElementById("dialog").scrollTo(0, 0);
};

const closeDialog = () => {
    document.getElementById("dialog").close();
};
</script>
<style>
dialog {
    width: 680px;
    max-height: 680px;
}
img {
    width: 640px;
    height: 640px;
    object-fit: contain;
    border: thin solid #000000;
    background-color: #000000;
}
#contributor, #temporal {font-size: small;}
</style>
</head>
<body onload="init()">
<p>絵画アーカイブ検索</p>
検索キーワード: <input type="text" id="keyword">
最大件数: <input type="number" id="size" value="20" min="20" max="500" step="10">
<input type="button" value="検索" onclick="query()">
<hr>
<div id="table"></div>
<dialog id="dialog">
    <div id="title"></div>
    <div id="image"></div>
    <div id="contributor"></div>
    <div id="temporal"></div>
    <br>
    <div id="description"></div>
    <hr>
    <input type="button" value="閉じる" onclick="closeDialog()">
</dialog>
</body>
</html>
エディターソフトなどにコピーしてHTMLとして保存します。
ブラウザで開きます。