Skip to main content

Simple Grid

目的

  • 開発環境がたくさんある状況で、串刺しで比較表を作るようなサンプル
  • Grid 風のコンポーネントが欲しくなるが、table で似たようなものを作って代替する

フォルダ構成

txt
src
├── api
│ ├── exec-sql.php
│ ├── list-app-config.php
│ ├── list-env-config.php
│ ├── list-env-configs.php
│ └── list-job-by-asofdate.php
├── cli
│ ├── generate-sample-data.php
│ └── show-configs.php
├── config
├── db
├── lib
│ ├── ConfigParser.php
│ ├── DBAccessor.php
│ ├── DBAccessorForSQLite.php
│ ├── EnvAccessPolicy.php
│ ├── JSONCParser.php
│ └── ResponseHelper.php
├── sql
└── www
├── js
│ ├── DataUtil.js
│ └── SimpleGrid.js
├── list-app-config.php
├── list-env-config-2.php
├── list-env-config.php
└── list-job-by-asofdate.php
api/*.php
php
==> src/api/exec-sql.php <==
<?php
require_once __DIR__ . '/../lib/ConfigParser.php';
require_once __DIR__ . '/../lib/DBAccessorForSQLite.php';
require_once __DIR__ . '/../lib/EnvAccessPolicy.php';
require_once __DIR__ . '/../lib/ResponseHelper.php';

send_header('application/json; charset=utf-8');

$envName = isset($_GET['envName']) ? trim($_GET['envName']) : '';
$sqlName = isset($_GET['sql']) ? trim($_GET['sql']) : '';

if ($envName === '' || $sqlName === '') {
http_response_code(400);
echo json_encode(array('error' => 'envName and sql are required'));
exit;
}

if (!preg_match('/^[a-zA-Z0-9_-]+$/', $sqlName)) {
http_response_code(400);
echo json_encode(array('error' => 'Invalid sql name'));
exit;
}

$sqlPath = __DIR__ . '/../sql/' . $sqlName . '.sql';
if (!file_exists($sqlPath)) {
http_response_code(404);
echo json_encode(array('error' => 'SQL file not found: ' . $sqlName));
exit;
}

if (!EnvAccessPolicy::canExecSql($envName, $sqlName)) {
http_response_code(403);
echo json_encode(array('error' => 'Access denied'));
exit;
}

$configDir = __DIR__ . '/../config';
$parser = new ConfigParser($configDir);
$parsed = $parser->parseEnvName($envName);
$envConfig = $parser->loadEnvConfigs();

$conn = isset($envConfig[$parsed['entityCode']][$parsed['envType']]['db1.conn'])
? $envConfig[$parsed['entityCode']][$parsed['envType']]['db1.conn']
: null;

if (!$conn) {
http_response_code(500);
echo json_encode(array('error' => 'db1.conn not found for ' . $envName));
exit;
}

$db = new DBAccessorForSQLite($conn);
$rows = $db->select(file_get_contents($sqlPath));

echo json_encode($rows, JSON_UNESCAPED_UNICODE);

==> src/api/list-app-config.php <==
<?php
require_once __DIR__ . '/../lib/ConfigParser.php';
require_once __DIR__ . '/../lib/ResponseHelper.php';

send_header('application/json; charset=utf-8');

$configDir = __DIR__ . '/../config';
$parser = new ConfigParser($configDir);
$appConfig = $parser->loadAppConfig();

$keygroupOrder = $parser->getKeygroupOrder();
$keygroupIndex = array_flip($keygroupOrder);

$rows = array();
foreach ($appConfig as $app => $entities) {
foreach ($entities as $entityCode => $envTypes) {
foreach ($envTypes as $envType => $keys) {
foreach ($keys as $key => $value) {
$rows[] = array(
'app' => $app,
'entityCode' => $entityCode,
'envType' => $envType,
'key' => $key,
'keygroup' => $parser->getKeygroup($key),
'value' => $value,
);
}
}
}
}

// Filter by ?keygroups=a,b or ?keygroup=a
$groupFilter = array();
if (!empty($_GET['keygroups'])) {
$groupFilter = array_map('trim', explode(',', $_GET['keygroups']));
} elseif (!empty($_GET['keygroup'])) {
$groupFilter = array(trim($_GET['keygroup']));
}
if (!empty($groupFilter)) {
$rows = array_values(array_filter($rows, function($r) use ($groupFilter) {
return in_array($r['keygroup'], $groupFilter);
}));
}

// Sort by keygroup definition order
usort($rows, function($a, $b) use ($keygroupIndex) {
$ai = isset($keygroupIndex[$a['keygroup']]) ? $keygroupIndex[$a['keygroup']] : PHP_INT_MAX;
$bi = isset($keygroupIndex[$b['keygroup']]) ? $keygroupIndex[$b['keygroup']] : PHP_INT_MAX;
return $ai - $bi;
});

echo json_encode($rows, JSON_UNESCAPED_UNICODE);

==> src/api/list-env-config.php <==
<?php
require_once __DIR__ . '/../lib/ConfigParser.php';
require_once __DIR__ . '/../lib/ResponseHelper.php';

send_header('application/json; charset=utf-8');

$configDir = __DIR__ . '/../config';
$parser = new ConfigParser($configDir);
$envConfig = $parser->loadEnvConfigs();

$keygroupOrder = $parser->getKeygroupOrder();
$keygroupIndex = array_flip($keygroupOrder);

$rows = array();
foreach ($envConfig as $entityCode => $envTypes) {
foreach ($envTypes as $envType => $keys) {
foreach ($keys as $key => $value) {
$rows[] = array(
'entityCode' => $entityCode,
'envType' => $envType,
'key' => $key,
'keygroup' => $parser->getKeygroup($key),
'value' => $value,
);
}
}
}

// Filter by ?keygroups=a,b or ?keygroup=a
$groupFilter = array();
if (!empty($_GET['keygroups'])) {
$groupFilter = array_map('trim', explode(',', $_GET['keygroups']));
} elseif (!empty($_GET['keygroup'])) {
$groupFilter = array(trim($_GET['keygroup']));
}
if (!empty($groupFilter)) {
$rows = array_values(array_filter($rows, function($r) use ($groupFilter) {
return in_array($r['keygroup'], $groupFilter);
}));
}

// Sort by entityCode → envType → keygroup definition order → key
usort($rows, function($a, $b) use ($keygroupIndex) {
$ec = strcmp($a['entityCode'], $b['entityCode']);
if ($ec !== 0) return $ec;
$et = strcmp($a['envType'], $b['envType']);
if ($et !== 0) return $et;
$ai = isset($keygroupIndex[$a['keygroup']]) ? $keygroupIndex[$a['keygroup']] : PHP_INT_MAX;
$bi = isset($keygroupIndex[$b['keygroup']]) ? $keygroupIndex[$b['keygroup']] : PHP_INT_MAX;
if ($ai !== $bi) return $ai - $bi;
return strcmp($a['key'], $b['key']);
});

echo json_encode($rows, JSON_UNESCAPED_UNICODE);

==> src/api/list-env-configs.php <==
<?php
require_once __DIR__ . '/../lib/ConfigParser.php';
require_once __DIR__ . '/../lib/ResponseHelper.php';

send_header('application/json; charset=utf-8');

$configDir = __DIR__ . '/../config';
$parser = new ConfigParser($configDir);

$appConfig = $parser->loadAppConfig();
$envConfig = $parser->loadEnvConfigs();

echo json_encode(array(
'appConfig' => $appConfig,
'envConfig' => $envConfig,
), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

==> src/api/list-job-by-asofdate.php <==
<?php
require_once __DIR__ . '/../lib/ConfigParser.php';
require_once __DIR__ . '/../lib/DBAccessorForSQLite.php';
require_once __DIR__ . '/../lib/ResponseHelper.php';

send_header('application/json; charset=utf-8');

$configDir = __DIR__ . '/../config';
$parser = new ConfigParser($configDir);
$envConfig = $parser->loadEnvConfigs();
$sql = file_get_contents(__DIR__ . '/../sql/jobs-by-asofdate.sql');

$rows = array();
foreach ($envConfig as $entityCode => $envTypes) {
foreach ($envTypes as $envType => $cfg) {
if (empty($cfg['db1.conn'])) continue;
try {
$db = new DBAccessorForSQLite($cfg['db1.conn']);
$results = $db->select($sql);
foreach ($results as $r) {
$rows[] = array(
'entityCode' => $entityCode,
'envType' => $envType,
'asofdate' => $r['asofdate'],
'total' => (int)$r['total'],
'complete' => (int)$r['complete'],
'fail' => (int)$r['fail'],
'accept' => (int)$r['accept'],
'waiting' => (int)$r['waiting'],
);
}
} catch (Exception $e) {
// Skip envs whose DB is unavailable
}
}
}

echo json_encode($rows, JSON_UNESCAPED_UNICODE);
cli/*.php
php
==> src/cli/generate-sample-data.php <==
<?php
// Usage: php cli/generate-sample-data.php

$baseDir = realpath(__DIR__ . '/..');
$configDir = $baseDir . '/config';
$dbDir = $baseDir . '/db';

if (!is_dir($dbDir)) mkdir($dbDir, 0755, true);

$entities = ['Entity1', 'Entity2', 'Entity3'];
$envTypes = ['Dev', 'Dev1', 'Dev2', 'UAT', 'Staging', 'Prod'];
$apps = ['proc-alpha', 'proc-beta', 'proc-gamma'];

$envList = [];
foreach ($entities as $entity) {
foreach ($envTypes as $envType) {
$envList[] = ['entity' => $entity, 'envType' => $envType, 'name' => $entity . $envType];
}
}
$envNames = array_column($envList, 'name');

mt_srand(42);

// ---- helpers ----

function mistake($prob = 0.08) { return (mt_rand(0, 99) / 100) < $prob; }
function pick($arr) { return $arr[array_rand($arr)]; }
function isProdLike($et) { return in_array($et, ['Prod', 'Staging', 'UAT']); }

// ---- app-params-config.csv ----

$appKeys = ['host', 'port', 'name', 'basedir', 'queue_size', 'use_cache', 'history_size'];
$portBases = ['proc-alpha' => 8100, 'proc-beta' => 8200, 'proc-gamma' => 8300];
$portOff = ['Dev' => 0, 'Dev1' => 1, 'Dev2' => 2, 'UAT' => 10, 'Staging' => 20, 'Prod' => 30];

function appValue($app, $entity, $envType, $key) {
global $portBases, $portOff;
$el = strtolower($entity);
$et = strtolower($envType);
switch ($key) {
case 'host':
$correct = isProdLike($envType)
? "$app-$el.internal"
: "$app-$el-$et.internal";
return mistake(0.08) ? "$app-$el.internal" : $correct;
case 'port':
$correct = $portBases[$app] + ($portOff[$envType] ?? 0);
return mistake(0.06) ? (string)$portBases[$app] : (string)$correct;
case 'name':
return $app;
case 'basedir':
return '/home/system-a/' . $app;
case 'queue_size':
if (mistake(0.08)) return '';
return isProdLike($envType) ? '1000' : '100';
case 'use_cache':
$correct = isProdLike($envType) ? 'true' : 'false';
return mistake(0.08) ? (isProdLike($envType) ? 'false' : 'true') : $correct;
case 'history_size':
if (mistake(0.08)) return '';
return $envType === 'Prod' ? '90' : '30';
}
return '';
}

$csvRows = [array_merge(['app', 'key'], $envNames)];
foreach ($apps as $app) {
foreach ($appKeys as $key) {
$row = [$app, $key];
foreach ($envList as $env) {
$row[] = appValue($app, $env['entity'], $env['envType'], $key);
}
$csvRows[] = $row;
}
}

$csvLines = [];
foreach ($csvRows as $row) {
$csvLines[] = implode(', ', $row);
}
$csvPath = $configDir . '/app-params-config.csv';
file_put_contents($csvPath, implode("\n", $csvLines) . "\n");
echo "Generated: $csvPath\n";

// ---- env-*.config ----

$calcModes = ['mode-a', 'mode-b', 'mode-c'];
$inputTypes = ['input-a', 'input-b'];

// Remove old env config files before regenerating
foreach (glob($configDir . '/env-*.config') as $f) { unlink($f); }

foreach ($envList as $env) {
$entity = $env['entity'];
$envType = $env['envType'];
$envName = $env['name'];
$el = strtolower($entity);
$et = strtolower($envType);

$keys = [
'db1.conn' => "/var/www/html/db/sample-{$envName}.sqlite3",
'db1.user' => "{$el}_{$et}_db1",
'db2.host' => "db2-$el-$et.internal",
'db2.port' => mistake(0.06) ? '5433' : '5432',
'db2.user' => "{$el}_{$et}_db2",
'db2.dbname' => "{$el}_{$et}_db2",
'calcmode' => mistake(0.1) ? pick($calcModes) : $calcModes[abs(crc32($entity)) % 3],
'inputtype' => mistake(0.1) ? pick($inputTypes) : $inputTypes[abs(crc32($entity)) % 2],
];

$lines = ["# $envName"];
foreach ($keys as $k => $v) {
if (mistake(0.05)) continue; // occasional missing key
$lines[] = "$k=$v";
}

$path = $configDir . '/env-' . $envName . '.config';
file_put_contents($path, implode("\n", $lines) . "\n");
echo "Generated: $path\n";
}

// ---- SQLite DBs ----

$requestTypes = ['type-a', 'type-b', 'type-c'];
$resultTypes = ['result-x', 'result-y', 'result-z'];

function randTime($daysAgo, $h = null) {
$base = mktime(0, 0, 0, (int)date('m'), (int)date('d') - $daysAgo, (int)date('Y'));
$h = $h ?? mt_rand(8, 17);
return date('Y-m-d H:i:s', $base + $h * 3600 + mt_rand(0, 3599));
}

foreach ($envList as $env) {
$envName = $env['name'];
$dbPath = $dbDir . '/sample-' . $envName . '.sqlite3';

if (file_exists($dbPath)) unlink($dbPath);

$pdo = new PDO('sqlite:' . $dbPath);
$pdo->exec('PRAGMA journal_mode=WAL');
$pdo->exec('CREATE TABLE jobs (
request_id TEXT PRIMARY KEY, status TEXT, request_type TEXT,
asofdate TEXT, started_at TEXT, ended_at TEXT
)');
$pdo->exec('CREATE TABLE results (
request_id TEXT, sequence_id INTEGER, asofdate TEXT,
result_type TEXT, status TEXT, value REAL, created_at TEXT
)');

$jStmt = $pdo->prepare('INSERT INTO jobs VALUES (?,?,?,?,?,?)');
$rStmt = $pdo->prepare('INSERT INTO results VALUES (?,?,?,?,?,?,?)');

$pdo->beginTransaction();
$seq = 0;
for ($day = 4; $day >= 0; $day--) {
$asofdate = date('Y-m-d', mktime(0, 0, 0, (int)date('m'), (int)date('d') - $day, (int)date('Y')));
foreach (range(1, mt_rand(8, 12)) as $_) {
$rid = sprintf('%s-%s-%04d', $envName, $asofdate, $seq++);
$rtype = $requestTypes[mt_rand(0, 2)];
$startedAt = randTime($day);
$endedAt = date('Y-m-d H:i:s', strtotime($startedAt) + mt_rand(30, 3600));
$r = mt_rand(0, 99);
$status = $r < 60 ? 'Complete' : ($r < 80 ? 'Accept' : ($r < 92 ? 'Fail' : 'Waiting'));

$jStmt->execute([$rid, $status, $rtype, $asofdate, $startedAt, $endedAt]);

foreach (range(1, mt_rand(25, 35)) as $s) {
$rStatus = mt_rand(0, 9) < 8 ? 'Complete' : 'Fail';
$rStmt->execute([
$rid, $s, $asofdate,
$resultTypes[mt_rand(0, 2)],
$rStatus,
round(mt_rand(0, 100000) / 100, 2),
date('Y-m-d H:i:s', strtotime($startedAt) + $s * mt_rand(1, 60)),
]);
}
}
}
$pdo->commit();
echo "Generated: $dbPath\n";
}

echo "Done.\n";

==> src/cli/show-configs.php <==
<?php
// Usage: php cli/show-configs.php
require_once __DIR__ . '/../lib/ConfigParser.php';

$configDir = __DIR__ . '/../config';
$parser = new ConfigParser($configDir);

$appConfig = $parser->loadAppConfig();
$envConfig = $parser->loadEnvConfigs();

echo "=== appConfig ===\n";
printf("%-10s %-10s %-12s %-12s %s\n", 'app', 'entity', 'envType', 'key', 'value');
echo str_repeat('-', 60) . "\n";
foreach ($appConfig as $app => $entities) {
foreach ($entities as $entity => $envTypes) {
foreach ($envTypes as $envType => $keys) {
foreach ($keys as $key => $value) {
printf("%-10s %-10s %-12s %-12s %s\n", $app, $entity, $envType, $key, $value);
}
}
}
}

echo "\n=== envConfig ===\n";
printf("%-10s %-12s %-20s %s\n", 'entity', 'envType', 'key', 'value');
echo str_repeat('-', 60) . "\n";
foreach ($envConfig as $entity => $envTypes) {
foreach ($envTypes as $envType => $keys) {
foreach ($keys as $key => $value) {
printf("%-10s %-12s %-20s %s\n", $entity, $envType, $key, $value);
}
}
}
lib/*.php
php
==> src/lib/ConfigParser.php <==
<?php
require_once __DIR__ . '/JSONCParser.php';

class ConfigParser {

private $configDir;
private $apiConfig;

public function __construct($configDir) {
$this->configDir = rtrim($configDir, '/');
$this->apiConfig = JSONCParser::parseFile($this->configDir . '/api-config.jsonc');
}

public function getApiConfig() {
return $this->apiConfig;
}

/**
* Load app-env-config.csv.
* Returns: appConfig[appName][entityCode][envType][key] = value
*/
public function loadAppConfig($filename = 'app-params-config.csv') {
$path = $this->configDir . '/' . $filename;
$lines = $this->readLines($path);
if (empty($lines)) return array();

$headers = array_map('trim', str_getcsv(array_shift($lines), ',', '"', ''));
$envNames = array_slice($headers, 2);

$result = array();
foreach ($lines as $line) {
if (trim($line) === '') continue;
$cols = array_map('trim', str_getcsv($line, ',', '"', ''));
$appName = $cols[0];
$key = $cols[1];
foreach ($envNames as $i => $envName) {
$value = isset($cols[$i + 2]) ? $cols[$i + 2] : '';
list($entityCode, $envType) = $this->splitEnvName($envName);
$result[$appName][$entityCode][$envType][$key] = $value;
}
}
return $result;
}

/**
* Load env-<EnvName>.config files.
* Returns: envConfig[entityCode][envType][key] = value
*/
public function loadEnvConfigs() {
$result = array();
foreach (glob($this->configDir . '/env-*.config') as $path) {
$envName = preg_replace('/^env-(.+)\.config$/', '$1', basename($path));
list($entityCode, $envType) = $this->splitEnvName($envName);
$result[$entityCode][$envType] = $this->parseProperties($path);
}
return $result;
}

/**
* Returns the keygroup name for a given key, or '' if not in any group.
*/
public function getKeygroup($key) {
$keygroups = isset($this->apiConfig['keygroups']) ? $this->apiConfig['keygroups'] : array();
foreach ($keygroups as $groupName => $keys) {
if (in_array($key, $keys)) return $groupName;
}
return '';
}

/**
* Returns keygroup names in definition order.
*/
public function getKeygroupOrder() {
$keygroups = isset($this->apiConfig['keygroups']) ? $this->apiConfig['keygroups'] : array();
return array_keys($keygroups);
}

/**
* Parse EnvName into ['entityCode' => ..., 'envType' => ...].
*/
public function parseEnvName($envName) {
list($entityCode, $envType) = $this->splitEnvName($envName);
return array('entityCode' => $entityCode, 'envType' => $envType);
}

/**
* Split EnvName into EntityCode and EnvType.
* Tries entityCodes longest-first to avoid prefix mismatches.
*/
private function splitEnvName($envName) {
foreach ($this->getEntitiesSortedByLength() as $entityCode) {
if (strpos($envName, $entityCode) === 0) {
return array($entityCode, substr($envName, strlen($entityCode)));
}
}
return array($envName, '');
}

private function getEntitiesSortedByLength() {
$entities = isset($this->apiConfig['entityCodes']) ? $this->apiConfig['entityCodes'] : array();
usort($entities, function($a, $b) { return strlen($b) - strlen($a); });
return $entities;
}

private function readLines($path) {
if (!file_exists($path)) return array();
return file($path, FILE_IGNORE_NEW_LINES);
}

private function parseProperties($path) {
$result = array();
foreach ($this->readLines($path) as $line) {
$line = trim($line);
if ($line === '' || $line[0] === '#') continue;
$pos = strpos($line, '=');
if ($pos === false) continue;
$result[trim(substr($line, 0, $pos))] = trim(substr($line, $pos + 1));
}
return $result;
}
}

==> src/lib/DBAccessor.php <==
<?php

abstract class DBAccessor {

protected $pdo;

public function select($sql, $params = array()) {
$this->guardSelectOnly($sql);
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}

private function guardSelectOnly($sql) {
$upper = strtoupper(ltrim(preg_replace('/\s+/', ' ', $sql)));
if (!preg_match('/^(SELECT|WITH)\b/', $upper)) {
throw new RuntimeException('Only SELECT/WITH queries are allowed.');
}
}
}

==> src/lib/DBAccessorForSQLite.php <==
<?php
require_once __DIR__ . '/DBAccessor.php';

class DBAccessorForSQLite extends DBAccessor {

public function __construct($connStr) {
$dsn = strpos($connStr, 'sqlite:') === 0 ? $connStr : 'sqlite:' . $connStr;
$this->pdo = new PDO($dsn);
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
}

==> src/lib/EnvAccessPolicy.php <==
<?php

class EnvAccessPolicy {

// Sample implementation: allow all.
// In production, restrict by envName, sqlName, user, etc.
public static function canExecSql($envName, $sqlName) {
return true;
}
}

==> src/lib/JSONCParser.php <==
<?php

class JSONCParser {

/**
* Parse a JSONC string (JSON with comments and trailing commas).
* Returns an associative array, or null on parse error.
*/
public static function parse($str) {
$stripped = self::strip($str);
return json_decode($stripped, true);
}

/**
* Parse a JSONC file.
*/
public static function parseFile($path) {
if (!file_exists($path)) return null;
return self::parse(file_get_contents($path));
}

private static function strip($str) {
// Remove // line comments and /* */ block comments
$str = preg_replace('!//[^\n]*|/\*.*?\*/!s', '', $str);
// Remove trailing commas before ] or }
$str = preg_replace('/,\s*([\]\}])/', '$1', $str);
return $str;
}
}

==> src/lib/ResponseHelper.php <==
<?php

function send_header($contentType) {
if (php_sapi_name() !== 'cli') {
header('Content-Type: ' . $contentType);
}
}
www/js/*.js
js
==> src/www/js/DataUtil.js <==
const DataUtil = {

convertCrossSection: function(items, rowKeys, colKey, valueKey) {
if (!valueKey) {
const rowKeySet = new Set(rowKeys);
const allKeys = items.length > 0 ? Object.keys(items[0]) : [];
valueKey = allKeys.find(k => k !== colKey && !rowKeySet.has(k));
}

// distinct col values (insertion order)
const colValues = [];
const colValueSet = new Set();
items.forEach(item => {
const v = String(item[colKey] ?? '');
if (!colValueSet.has(v)) { colValueSet.add(v); colValues.push(v); }
});

// group by rowKeys combination
const rowMap = new Map();
const rowOrder = [];
items.forEach(item => {
const mapKey = rowKeys.map(k => String(item[k] ?? '')).join('\0');
if (!rowMap.has(mapKey)) {
const row = {};
rowKeys.forEach(k => { row[k] = item[k]; });
rowMap.set(mapKey, row);
rowOrder.push(mapKey);
}
rowMap.get(mapKey)[String(item[colKey] ?? '')] = item[valueKey];
});

const columns = [
...rowKeys.map(k => ({ key: k, header: k })),
...colValues.map(v => ({ key: v, header: v })),
];

return { items: rowOrder.map(k => rowMap.get(k)), columns };
},

};

==> src/www/js/SimpleGrid.js <==
function SimpleGrid(selectorOrConfig, config) {
const self = {
items: [],
frozenRows: 1,
frozenColumns: 0,
columns: null,
columnFilters: null,
groupBy: null,
onFilterChange: null,
};

let _selector = null;
let _table = null;
let _colDefs = [];
let _filterDefs = [];
let _filterSelections = {}; // key -> Set<string>
let _filterPaneEls = {}; // key -> { input, select }
let _wrapper = null;
let _gridArea = null;
let _sortKey = null;
let _sortDir = null; // 'asc' | 'desc' | null

const CONFIG_KEYS = ['items', 'frozenRows', 'frozenColumns', 'columns', 'columnFilters', 'groupBy', 'onFilterChange'];

function applyConfig(cfg) {
if (!cfg) return;
CONFIG_KEYS.forEach(k => { if (k in cfg) self[k] = cfg[k]; });
if (cfg.el) _selector = cfg.el;
}

// ---- built-in style ----

const BUILTIN_RULES = [
{ patterns: ['ok', 'succe*', 'complete', 'o'], bg: '#e6f4ea', fg: '#1a6e2e' },
{ patterns: ['ng', '*error*', '*fail*', 'x'], bg: '#fce8e6', fg: '#b31412' },
{ patterns: ['*warn*'], bg: '#fef7e0', fg: '#7a5c00' },
{ patterns: ['-', ''], bg: '#f0f0f0', fg: '#888888' },
];
const BUILTIN_CENTER = new Set(['o', 'x', '-']);

function matchPattern(value, pattern) {
const v = value.toLowerCase();
const p = pattern.toLowerCase();
if (p.startsWith('*') && p.endsWith('*')) return v.includes(p.slice(1, -1));
if (p.startsWith('*')) return v.endsWith(p.slice(1));
if (p.endsWith('*')) return v.startsWith(p.slice(0, -1));
return v === p;
}

function getBuiltinStyle(value, col) {
if (!col.useBuiltinStyle) return null;
const v = String(value);
const result = {};
for (const rule of BUILTIN_RULES) {
if (rule.patterns.some(p => matchPattern(v, p))) {
result.bg = rule.bg;
result.fg = rule.fg;
break;
}
}
if (BUILTIN_CENTER.has(v.toLowerCase())) result.align = 'center';
return Object.keys(result).length > 0 ? result : null;
}

// ---- column defs ----

function resolveColDefs() {
if (!self.columns) {
const keys = self.items.length > 0 ? Object.keys(self.items[0]) : [];
return keys.map(k => ({ key: k, header: k }));
}
return self.columns.map(c => {
if (typeof c === 'string') return { key: c, header: c };
return {
key: c.key,
header: c.header != null ? c.header : c.key,
backgroundColor: c.backgroundColor || null,
foregroundColor: c.foregroundColor || null,
horizontalAlign: c.horizontalAlign || null,
verticalAlign: c.verticalAlign || null,
width: c.width || null,
useBuiltinStyle: c.useBuiltinStyle || false,
};
});
}

// ---- filter logic ----

function resolveFilterDefs() {
if (!self.columnFilters) return [];
return self.columnFilters.map(f => {
if (typeof f === 'string') return { key: f, values: null };
return { key: f.key, values: f.values || null };
});
}

function getFilteredItems(upToIndex) {
let result = self.items;
for (let i = 0; i < upToIndex; i++) {
const sel = _filterSelections[_filterDefs[i].key];
if (sel && sel.size > 0) {
result = result.filter(item => sel.has(String(item[_filterDefs[i].key] ?? '')));
}
}
return result;
}

function getDistinctValues(items, key) {
const seen = new Set();
const result = [];
items.forEach(item => {
const v = String(item[key] ?? '');
if (!seen.has(v)) { seen.add(v); result.push(v); }
});
return result.sort();
}

function updatePaneOptions(paneIndex) {
const def = _filterDefs[paneIndex];
const els = _filterPaneEls[def.key];
if (!els) return;

const upstream = getFilteredItems(paneIndex);
const values = def.values || getDistinctValues(upstream, def.key);
const selected = _filterSelections[def.key] || new Set();
const search = els.input.value.toLowerCase();

els.select.innerHTML = '';
values.forEach(v => {
if (search && !v.toLowerCase().includes(search)) return;
const opt = document.createElement('option');
opt.value = v;
opt.textContent = v;
opt.selected = selected.has(v);
els.select.appendChild(opt);
});
}

function onFilterChange(paneIndex) {
const def = _filterDefs[paneIndex];
const els = _filterPaneEls[def.key];
_filterSelections[def.key] = new Set(
Array.from(els.select.selectedOptions).map(o => o.value)
);
for (let i = paneIndex + 1; i < _filterDefs.length; i++) {
updatePaneOptions(i);
}
buildBody(sortItems(getFilteredItems(_filterDefs.length)));
setFrozenLeft();
if (self.onFilterChange) {
self.onFilterChange(getFilteredItems(_filterDefs.length));
}
}

function buildFilterArea(filterArea) {
filterArea.innerHTML = '';
_filterPaneEls = {};
_filterDefs.forEach((def, i) => {
const pane = document.createElement('div');
pane.className = 'sg-filter-pane';

const label = document.createElement('div');
label.className = 'sg-filter-label';
label.textContent = def.key;

const input = document.createElement('input');
input.type = 'text';
input.className = 'sg-filter-search';
input.placeholder = 'Filter...';

const select = document.createElement('select');
select.multiple = true;
select.className = 'sg-filter-select';

_filterPaneEls[def.key] = { input, select };

input.addEventListener('input', () => updatePaneOptions(i));
select.addEventListener('change', () => onFilterChange(i));

pane.appendChild(label);
pane.appendChild(input);
pane.appendChild(select);
filterArea.appendChild(pane);

updatePaneOptions(i);
});
}

// ---- sort ----

function sortItems(items) {
if (!_sortKey || !_sortDir) return items;
return [...items].sort((a, b) => {
const va = a[_sortKey] ?? '';
const vb = b[_sortKey] ?? '';
const na = parseFloat(va), nb = parseFloat(vb);
const isNum = !isNaN(na) && !isNaN(nb);
const cmp = isNum ? na - nb : String(va).localeCompare(String(vb), 'ja');
return _sortDir === 'desc' ? -cmp : cmp;
});
}

function onSortClick(key) {
if (_sortKey === key) {
if (_sortDir === 'asc') _sortDir = 'desc';
else { _sortKey = null; _sortDir = null; }
} else {
_sortKey = key;
_sortDir = 'asc';
}
buildHeader();
buildBody(sortItems(getFilteredItems(_filterDefs.length)));
setFrozenLeft();
}

// ---- table ----

function buildHeader() {
const thead = _table.querySelector('thead');
thead.innerHTML = '';
const tr = document.createElement('tr');
_colDefs.forEach((col, i) => {
const th = document.createElement('th');
th.className = 'sg-th';
if (i < self.frozenColumns) th.classList.add('sg-frozen');
if (col.width) th.style.width = col.width + 'px';

const inner = document.createElement('div');
inner.className = 'sg-th-inner';

const label = document.createElement('span');
label.className = 'sg-th-label';
label.textContent = col.header;

const sortEl = document.createElement('span');
sortEl.className = 'sg-th-sort';
sortEl.textContent = _sortKey === col.key
? (_sortDir === 'asc' ? '▲' : '▼') : '';

inner.appendChild(label);
inner.appendChild(sortEl);
th.appendChild(inner);
th.addEventListener('click', () => onSortClick(col.key));
tr.appendChild(th);
});
thead.appendChild(tr);
}

function getGroupKey(item) {
if (!self.groupBy) return null;
const keys = Array.isArray(self.groupBy) ? self.groupBy : [self.groupBy];
return keys.map(k => item[k] ?? '').join('\0');
}

function buildBody(items) {
const tbody = _table.querySelector('tbody');
tbody.innerHTML = '';
const fragment = document.createDocumentFragment();
let prevGroupKey = null;
items.forEach(item => {
const groupKey = getGroupKey(item);
const tr = document.createElement('tr');
tr.className = 'sg-row';
if (groupKey !== null && prevGroupKey !== null && groupKey !== prevGroupKey) {
tr.classList.add('sg-group-start');
}
prevGroupKey = groupKey;
_colDefs.forEach((col, i) => {
const td = document.createElement('td');
const val = item[col.key] != null ? item[col.key] : '';
td.textContent = val;
td.title = String(val);
td.className = 'sg-td';
if (i < self.frozenColumns) td.classList.add('sg-frozen');
if (col.width) td.style.maxWidth = col.width + 'px';
if (col.verticalAlign) td.style.verticalAlign = col.verticalAlign;

const builtin = getBuiltinStyle(val, col);

const bg = (col.backgroundColor
? (typeof col.backgroundColor === 'function' ? col.backgroundColor(item) : col.backgroundColor)
: null) ?? builtin?.bg ?? null;
if (bg) td.style.backgroundColor = bg;

const fg = (col.foregroundColor
? (typeof col.foregroundColor === 'function' ? col.foregroundColor(item) : col.foregroundColor)
: null) ?? builtin?.fg ?? null;
if (fg) td.style.color = fg;

const align = col.horizontalAlign ?? builtin?.align ?? null;
if (align) td.style.textAlign = align;
tr.appendChild(td);
});
fragment.appendChild(tr);
});
tbody.appendChild(fragment);
}

function setFrozenLeft() {
if (self.frozenColumns === 0) return;
const headerCells = Array.from(_table.querySelectorAll('thead .sg-frozen'));
let left = 0;
const offsets = headerCells.map(th => {
const o = left;
left += th.offsetWidth;
return o;
});
_table.querySelectorAll('.sg-frozen').forEach((el, j) => {
el.style.left = offsets[j % self.frozenColumns] + 'px';
});
}

// ---- styles ----

function injectStyles() {
if (document.getElementById('sg-style')) return;
const s = document.createElement('style');
s.id = 'sg-style';
s.textContent = [
'.sg-wrapper { height: 100%; display: flex; flex-direction: column; }',
'.sg-filter-area { flex: none; display: flex; flex-direction: row; border-bottom: 1px solid #bbb; background: #f8f8f8; }',
'.sg-filter-pane { display: flex; flex-direction: column; border-right: 1px solid #ccc; padding: 4px; min-width: 130px; }',
'.sg-filter-label { font-size: 11px; color: #666; margin-bottom: 2px; font-weight: bold; }',
'.sg-filter-search { padding: 2px 4px; margin-bottom: 3px; font-size: 12px; border: 1px solid #ccc; }',
'.sg-filter-select { flex: 1; min-height: 100px; font-size: 12px; border: 1px solid #ccc; }',
'.sg-grid-area { flex: 1; overflow: auto; }',
'.sg-table { border-collapse: collapse; white-space: nowrap; }',
'.sg-th { position: sticky; top: 0; background: #dde; z-index: 2; cursor: pointer; }',
'.sg-th:hover { filter: brightness(0.93); }',
'.sg-th, .sg-td { border: 1px solid #ccc; padding: 3px 8px; overflow: hidden; text-overflow: ellipsis; }',
'.sg-th-inner { display: flex; align-items: center; gap: 2px; }',
'.sg-th-label { flex: 1; text-align: center; }',
'.sg-th-sort { flex: none; width: 1em; text-align: right; font-size: 9px; color: #aaa; }',
'.sg-frozen { position: sticky; background: #f5f5f5; z-index: 1; }',
'thead .sg-frozen { background: #ccd; z-index: 3; }',
'.sg-row:hover .sg-td { background: #fffbe6; }',
'.sg-row:hover .sg-frozen.sg-td { background: #f5f0d0; }',
'.sg-group-start .sg-td { border-top: 2px solid #999; }',
].join('\n');
document.head.appendChild(s);
}

// ---- draw ----

self.getFilteredItems = function() {
return getFilteredItems(_filterDefs.length);
};

self.draw = function(selectorOrConfig, config) {
if (typeof selectorOrConfig === 'string') {
_selector = selectorOrConfig;
applyConfig(config);
} else {
applyConfig(selectorOrConfig);
}
const container = document.querySelector(_selector);
if (!container) return;

injectStyles();
_colDefs = resolveColDefs();
_filterDefs = resolveFilterDefs();

if (!_wrapper) {
_wrapper = document.createElement('div');
_wrapper.className = 'sg-wrapper';
container.appendChild(_wrapper);
}

if (_filterDefs.length > 0) {
let filterArea = _wrapper.querySelector('.sg-filter-area');
if (!filterArea) {
filterArea = document.createElement('div');
filterArea.className = 'sg-filter-area';
_wrapper.insertBefore(filterArea, _wrapper.firstChild);
}
buildFilterArea(filterArea);
}

if (!_gridArea) {
_gridArea = document.createElement('div');
_gridArea.className = 'sg-grid-area';
_wrapper.appendChild(_gridArea);
}

const tableParent = _gridArea;
if (!_table) {
_table = document.createElement('table');
_table.className = 'sg-table';
_table.appendChild(document.createElement('thead'));
_table.appendChild(document.createElement('tbody'));
tableParent.appendChild(_table);
}

buildHeader();
buildBody(sortItems(getFilteredItems(_filterDefs.length)));
setFrozenLeft();
};

// Handle constructor arguments
if (typeof selectorOrConfig === 'string') {
_selector = selectorOrConfig;
applyConfig(config);
} else {
applyConfig(selectorOrConfig);
}

return self;
}
www/*.php
php
==> src/www/list-app-config.php <==
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>App Config</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }
#header { flex: none; padding: 8px 12px; background: #f0f0f0; border-bottom: 1px solid #ccc; }
#header h1 { font-size: 15px; margin-bottom: 4px; }
#header label { cursor: pointer; user-select: none; }
#grid-container { flex: 1; overflow: hidden; }
</style>
<script src="js/SimpleGrid.js"></script>
<script src="js/DataUtil.js"></script>
</head>
<body>
<div id="header">
<h1>App Config</h1>
<p>
<label>
<input type="checkbox" id="chk-cross"> Cross view (rows: app/entity/key, cols: envType)
</label>
</p>
</div>
<div id="grid-container"></div>

<script>
const RAW_COLUMNS = [
{ key: 'app', header: 'App', width: 80 },
{ key: 'entityCode', header: 'Entity', width: 80 },
{ key: 'envType', header: 'EnvType', width: 80, horizontalAlign: 'center' },
{ key: 'key', header: 'Key', width: 120 },
{ key: 'value', header: 'Value', width: 160 },
];

const grid = SimpleGrid();
grid.frozenRows = 1;
grid.groupBy = 'keygroup';

let rawItems = [];

function redraw() {
const isCross = document.getElementById('chk-cross').checked;
if (isCross) {
grid.frozenColumns = 3;
const result = DataUtil.convertCrossSection(rawItems, ['app', 'entityCode', 'key'], 'envType', 'value');
grid.items = result.items;
grid.columns = result.columns.map((col, i) => ({
...col,
width: i < 3 ? (i === 2 ? 140 : 80) : 100,
horizontalAlign: i < 3 ? null : 'left',
}));
} else {
grid.frozenColumns = 4;
grid.items = rawItems;
grid.columns = RAW_COLUMNS;
}
grid.draw('#grid-container');
}

document.getElementById('chk-cross').addEventListener('change', redraw);

fetch('../api/list-app-config.php')
.then(r => r.json())
.then(data => {
rawItems = data;
redraw();
});
</script>
</body>
</html>

==> src/www/list-env-config-2.php <==
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Env Config — Cross View</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }
#header { flex: none; padding: 8px 12px; background: #f0f0f0; border-bottom: 1px solid #ccc; }
#header h1 { font-size: 15px; }
#grid-container { flex: 1; overflow: hidden; }
</style>
<script src="js/SimpleGrid.js"></script>
<script src="js/DataUtil.js"></script>
</head>
<body>
<div id="header">
<h1>Env Config — Key × EnvName</h1>
</div>
<div id="grid-container"></div>

<script>
const grid = SimpleGrid();
grid.frozenRows = 1;
grid.frozenColumns = 2;
grid.groupBy = 'keygroup';

function findProdCol(colKey, colKeys) {
const prodCols = colKeys
.filter(k => k.endsWith('Prod'))
.sort((a, b) => b.length - a.length); // longest prefix first
for (const p of prodCols) {
if (colKey.startsWith(p.slice(0, -4))) return p;
}
return null;
}

fetch('../api/list-env-config.php')
.then(r => r.json())
.then(data => {
const items = data.map(r => ({ ...r, envName: r.entityCode + r.envType }));
const result = DataUtil.convertCrossSection(items, ['keygroup', 'key'], 'envName', 'value');
const envColKeys = result.columns.slice(2).map(c => c.key);

grid.columns = result.columns.map((col, i) => {
const def = { ...col, width: i === 0 ? 80 : i === 1 ? 140 : 120 };
if (i >= 2 && !col.key.endsWith('Prod')) {
const prodCol = findProdCol(col.key, envColKeys);
if (prodCol) {
const ck = col.key;
def.backgroundColor = item => {
const val = item[ck] ?? '';
const prodVal = item[prodCol] ?? '';
return val !== prodVal ? '#fff9c4' : null;
};
}
}
return def;
});

grid.items = result.items;
grid.draw('#grid-container');
});
</script>
</body>
</html>

==> src/www/list-env-config.php <==
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Env Config</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }
#header { flex: none; padding: 8px 12px; background: #f0f0f0; border-bottom: 1px solid #ccc; }
#header h1 { font-size: 15px; margin-bottom: 4px; }
#header label { cursor: pointer; user-select: none; }
#grid-container { flex: 1; overflow: hidden; }
</style>
<script src="js/SimpleGrid.js"></script>
<script src="js/DataUtil.js"></script>
</head>
<body>
<div id="header">
<h1>Env Config</h1>
<p>
<label>
<input type="checkbox" id="chk-cross"> Cross view (rows: entity/key, cols: envType)
</label>
</p>
</div>
<div id="grid-container"></div>

<script>
const RAW_COLUMNS = [
{ key: 'entityCode', header: 'Entity', width: 80 },
{ key: 'envType', header: 'EnvType', width: 80, horizontalAlign: 'center' },
{ key: 'key', header: 'Key', width: 160 },
{ key: 'value', header: 'Value', width: 260 },
];

const grid = SimpleGrid();
grid.frozenRows = 1;
grid.groupBy = ['entityCode', 'envType', 'keygroup'];

let rawItems = [];

function redraw() {
const isCross = document.getElementById('chk-cross').checked;
if (isCross) {
grid.frozenColumns = 2;
const result = DataUtil.convertCrossSection(rawItems, ['entityCode', 'key'], 'envType', 'value');
grid.items = result.items;
grid.columns = result.columns.map((col, i) => ({
...col,
width: i < 2 ? (i === 1 ? 160 : 80) : 200,
horizontalAlign: null,
}));
} else {
grid.frozenColumns = 3;
grid.items = rawItems;
grid.columns = RAW_COLUMNS;
}
grid.draw('#grid-container');
}

document.getElementById('chk-cross').addEventListener('change', redraw);

fetch('../api/list-env-config.php')
.then(r => r.json())
.then(data => {
rawItems = data;
redraw();
});
</script>
</body>
</html>

==> src/www/list-job-by-asofdate.php <==
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Jobs by Date</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }
#header { flex: none; padding: 8px 12px; background: #f0f0f0; border-bottom: 1px solid #ccc; }
#header h1 { font-size: 15px; margin-bottom: 4px; }
#header label { cursor: pointer; user-select: none; }
#filter-container { flex: none; overflow: hidden; }
#filter-container .sg-wrapper { height: auto; }
#filter-container .sg-grid-area { display: none; }
#grid-container { flex: 1; overflow: hidden; }
</style>
<script src="js/SimpleGrid.js"></script>
<script src="js/DataUtil.js"></script>
</head>
<body>
<div id="header">
<h1>Jobs by Date</h1>
<p>
<label>
<input type="checkbox" id="chk-cross"> Cross view (rows: entity/envType, cols: asofdate)
</label>
</p>
</div>
<div id="filter-container"></div>
<div id="grid-container"></div>

<script>
const RAW_COLUMNS = [
{ key: 'entityCode', header: 'Entity', width: 80 },
{ key: 'envType', header: 'EnvType', width: 80 },
{ key: 'asofdate', header: 'Date', width: 100 },
{ key: 'total', header: 'Total', width: 60, horizontalAlign: 'right' },
{ key: 'complete', header: 'Complete', width: 70, horizontalAlign: 'right' },
{ key: 'fail', header: 'Fail', width: 50, horizontalAlign: 'right' },
{ key: 'accept', header: 'Accept', width: 60, horizontalAlign: 'right' },
{ key: 'waiting', header: 'Waiting', width: 60, horizontalAlign: 'right' },
];

let rawItems = [];
let filteredItems = [];

const filterGrid = SimpleGrid({
columnFilters: ['entityCode', 'envType', 'asofdate'],
onFilterChange: function(items) {
filteredItems = items;
redrawDisplay();
},
});

const displayGrid = SimpleGrid({
frozenRows: 1,
frozenColumns: 3,
});

function redrawDisplay() {
const isCross = document.getElementById('chk-cross').checked;
if (isCross) {
displayGrid.frozenColumns = 2;
displayGrid.columnFilters = null;
const result = DataUtil.convertCrossSection(filteredItems, ['entityCode', 'envType'], 'asofdate', 'total');
displayGrid.columns = result.columns.map((col, i) => ({
...col,
width: i < 2 ? 80 : 70,
horizontalAlign: i < 2 ? null : 'right',
}));
displayGrid.items = result.items;
} else {
displayGrid.frozenColumns = 3;
displayGrid.columnFilters = null;
displayGrid.columns = RAW_COLUMNS;
displayGrid.items = filteredItems;
}
displayGrid.draw('#grid-container');
}

document.getElementById('chk-cross').addEventListener('change', redrawDisplay);

fetch('../api/list-job-by-asofdate.php')
.then(r => r.json())
.then(data => {
rawItems = data.filter(r => r.envType !== 'Prod');
filteredItems = rawItems;
filterGrid.items = rawItems;
filterGrid.draw('#filter-container');
redrawDisplay();
});
</script>
</body>
</html>

==> src/www/sample-layout-grid-base.html <==
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sample Layout - Grid Component</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }
#header { flex: none; padding: 8px 12px; background: #f0f0f0; border-bottom: 1px solid #ccc; }
#header h1 { font-size: 15px; margin-bottom: 4px; }
#grid-container { flex: 1; overflow: hidden; }
</style>
<script src="js/SimpleGrid.js"></script>
</head>
<body>
<div id="header">
<h1>Sample Layout — Grid コンポーネント</h1>
<p>SimpleGrid を使った行列固定テーブル / 200行 × 20/ 先頭2列固定</p>
</div>
<div id="grid-container"></div>

<script>
const ROWS = 200;
const DATA_COLS = 18;
const categories = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'];

const items = [];
for (let r = 1; r <= ROWS; r++) {
const item = {
no: r,
name: categories[(r - 1) % categories.length] + '-' + String(r).padStart(3, '0'),
};
for (let c = 1; c <= DATA_COLS; c++) {
item['col' + c] = parseFloat((Math.random() * 9999).toFixed(2));
}
items.push(item);
}

const grid = SimpleGrid();
grid.items = items;
grid.frozenRows = 1;
grid.frozenColumns = 2;
grid.columns = [
{ key: 'no', header: '#', width: 40, horizontalAlign: 'right' },
{ key: 'name', header: 'Name', width: 120 },
...Array.from({ length: DATA_COLS }, (_, i) => {
const key = 'col' + (i + 1);
return {
key,
header: 'Col-' + (i + 1),
width: 80,
horizontalAlign: 'right',
backgroundColor: item => item[key] > 9000 ? '#fdd' : null,
};
}),
];
grid.draw('#grid-container');
</script>
</body>
</html>

==> src/www/sample-layout-grid-column-filter.html <==
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sample Layout - Grid with Column Filter</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }
#header { flex: none; padding: 8px 12px; background: #f0f0f0; border-bottom: 1px solid #ccc; }
#header h1 { font-size: 15px; margin-bottom: 4px; }
#grid-container { flex: 1; overflow: hidden; }
</style>
<script src="js/SimpleGrid.js"></script>
</head>
<body>
<div id="header">
<h1>Sample Layout — Column Filter</h1>
<p>カラムブラウザ風フィルタ + Grid / 200行 × 15</p>
</div>
<div id="grid-container"></div>

<script>
const categories = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'];
const subcategories = ['X', 'Y', 'Z'];
const statuses = ['active', 'inactive', 'pending'];

const items = [];
for (let r = 1; r <= 200; r++) {
const item = {
no: r,
category: categories[(r - 1) % categories.length],
subcategory: subcategories[(r - 1) % subcategories.length],
status: statuses[(r - 1) % statuses.length],
};
for (let c = 1; c <= 11; c++) {
item['col' + c] = parseFloat((Math.random() * 9999).toFixed(2));
}
items.push(item);
}

const grid = SimpleGrid();
grid.items = items;
grid.frozenRows = 1;
grid.frozenColumns = 2;
grid.columnFilters = ['category', 'subcategory', 'status'];
grid.columns = [
{ key: 'no', header: '#', width: 40, horizontalAlign: 'right' },
{ key: 'category', header: 'Category', width: 90 },
{ key: 'subcategory', header: 'Sub', width: 50, horizontalAlign: 'center' },
{ key: 'status', header: 'Status', width: 70,
foregroundColor: item => item.status === 'inactive' ? '#999' : null },
...Array.from({ length: 11 }, (_, i) => {
const key = 'col' + (i + 1);
return {
key,
header: 'Col-' + (i + 1),
width: 80,
horizontalAlign: 'right',
backgroundColor: item => item[key] > 9000 ? '#fdd' : null,
};
}),
];
grid.draw('#grid-container');
</script>
</body>
</html>

==> src/www/sample-layout-grid-cross-section.html <==
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sample Layout - Cross Section</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }
#header { flex: none; padding: 8px 12px; background: #f0f0f0; border-bottom: 1px solid #ccc; }
#header h1 { font-size: 15px; margin-bottom: 4px; }
#header label { cursor: pointer; user-select: none; }
#grid-container { flex: 1; overflow: hidden; }
</style>
<script src="js/SimpleGrid.js"></script>
<script src="js/DataUtil.js"></script>
</head>
<body>
<div id="header">
<h1>Sample Layout — Cross Section</h1>
<p>
<label>
<input type="checkbox" id="chk-cross"> クロス集計表示
</label>
</p>
</div>
<div id="grid-container"></div>

<script>
// サンプルデータ: category × subcategory × metric の組み合わせ
const categories = ['Alpha', 'Beta', 'Gamma'];
const subcategories = ['X', 'Y', 'Z'];
const metrics = ['score', 'count', 'rate'];

const rawItems = [];
categories.forEach(cat => {
subcategories.forEach(sub => {
metrics.forEach(metric => {
rawItems.push({
category: cat,
subcategory: sub,
metric: metric,
value: parseFloat((Math.random() * 100).toFixed(2)),
});
});
});
});

const rawColumns = [
{ key: 'category', header: 'Category', width: 80 },
{ key: 'subcategory', header: 'Sub', width: 50, horizontalAlign: 'center' },
{ key: 'metric', header: 'Metric', width: 70 },
{ key: 'value', header: 'Value', width: 70, horizontalAlign: 'right' },
];

const grid = SimpleGrid();
grid.frozenRows = 1;

function redraw() {
const isCross = document.getElementById('chk-cross').checked;
if (isCross) {
grid.frozenColumns = 2;
const result = DataUtil.convertCrossSection(rawItems, ['category', 'subcategory'], 'metric', 'value');
grid.items = result.items;
grid.columns = result.columns.map((col, i) => ({
...col,
width: i < 2 ? 80 : 70,
horizontalAlign: i < 2 ? null : 'right',
}));
} else {
grid.frozenColumns = 3;
grid.items = rawItems;
grid.columns = rawColumns;
}
grid.draw('#grid-container');
}

document.getElementById('chk-cross').addEventListener('change', redraw);
redraw();
</script>
</body>
</html>

==> src/www/sample-layout-grid-style.html <==
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sample Layout - Built-in Style</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }
#header { flex: none; padding: 8px 12px; background: #f0f0f0; border-bottom: 1px solid #ccc; }
#header h1 { font-size: 15px; margin-bottom: 4px; }
#grid-container { flex: 1; overflow: hidden; }
</style>
<script src="js/SimpleGrid.js"></script>
</head>
<body>
<div id="header">
<h1>Sample Layout — Built-in Style</h1>
<p>useBuiltinStyle による自動色付け / 列定義の色指定が null のときフォールバック</p>
</div>
<div id="grid-container"></div>

<script>
const items = [
{ check: 'o', env: 'dev', service: 'api', status: 'OK', latency: 42, note: 'stable' },
{ check: 'o', env: 'dev', service: 'db', status: 'Success', latency: 8, note: '' },
{ check: 'x', env: 'dev', service: 'cache', status: 'NG', latency: null, note: 'connection error' },
{ check: '-', env: 'dev', service: 'batch', status: '-', latency: '-', note: '-' },
{ check: 'o', env: 'stg', service: 'api', status: 'OK', latency: 55, note: '' },
{ check: 'x', env: 'stg', service: 'db', status: 'Failed', latency: null, note: 'timeout error' },
{ check: 'o', env: 'stg', service: 'cache', status: 'Succeeded', latency: 3, note: '' },
{ check: '-', env: 'stg', service: 'batch', status: '-', latency: '-', note: 'not deployed' },
{ check: 'o', env: 'prod', service: 'api', status: 'OK', latency: 38, note: '' },
{ check: 'o', env: 'prod', service: 'db', status: 'OK', latency: 6, note: '' },
{ check: 'x', env: 'prod', service: 'cache', status: 'NG', latency: null, note: 'Warning: high memory' },
{ check: 'o', env: 'prod', service: 'batch', status: 'OK', latency: 120, note: '' },
];

const grid = SimpleGrid('#grid-container', {
useBuiltinStyle: true,
frozenRows: 1,
frozenColumns: 2,
items,
columns: [
{ key: 'check', header: '✓', width: 30 },
{ key: 'env', header: 'Env', width: 50 },
{ key: 'service', header: 'Service', width: 80 },
{ key: 'status', header: 'Status', width: 90 },
{ key: 'latency', header: 'Latency', width: 70, horizontalAlign: 'right' },
{ key: 'note', header: 'Note', width: 180 },
],
});
grid.draw();
</script>
</body>
</html>

==> src/www/sample-layout-js-table.html <==
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Sample Layout</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; overflow: hidden; }
body { display: flex; flex-direction: column; font-size: 13px; font-family: sans-serif; }

#header {
flex: none;
padding: 8px 12px;
background: #f0f0f0;
border-bottom: 1px solid #ccc;
}
#header h1 { font-size: 15px; margin-bottom: 4px; }

#grid-container {
flex: 1;
overflow: auto;
}

table {
border-collapse: collapse;
white-space: nowrap;
}
th, td {
border: 1px solid #ccc;
padding: 3px 8px;
min-width: 80px;
}
thead th {
position: sticky;
top: 0;
background: #dde;
z-index: 2;
}
/* 固定列は JS で left を設定 */
.frozen {
position: sticky;
background: #f5f5f5;
z-index: 1;
}
thead .frozen {
background: #ccd;
z-index: 3;
}
tbody tr:hover td { background: #fffbe6; }
tbody tr:hover .frozen { background: #f5f0d0; }
</style>
</head>
<body>
<div id="header">
<h1>Sample Layout — 行列固定テーブル</h1>
<p>先頭1行・2列固定 / 200行 × 20</p>
</div>
<div id="grid-container">
<table id="tbl">
<thead id="thead"></thead>
<tbody id="tbody"></tbody>
</table>
</div>

<script>
var ROWS = 200;
var TOTAL_COLS = 20;
var FROZEN_COLS = 2;

var categories = ['Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon'];

function buildHeader() {
var tr = document.createElement('tr');
var headers = ['#', 'Name'];
for (var c = 1; c <= TOTAL_COLS - FROZEN_COLS; c++) {
headers.push('Col-' + c);
}
for (var i = 0; i < headers.length; i++) {
var th = document.createElement('th');
th.textContent = headers[i];
if (i < FROZEN_COLS) th.classList.add('frozen');
tr.appendChild(th);
}
document.getElementById('thead').appendChild(tr);
}

function buildBody() {
var fragment = document.createDocumentFragment();
for (var r = 1; r <= ROWS; r++) {
var tr = document.createElement('tr');

var tdNum = document.createElement('td');
tdNum.textContent = r;
tdNum.classList.add('frozen');
tr.appendChild(tdNum);

var tdName = document.createElement('td');
tdName.textContent = categories[(r - 1) % categories.length] + '-' + String(r).padStart(3, '0');
tdName.classList.add('frozen');
tr.appendChild(tdName);

for (var c = 1; c <= TOTAL_COLS - FROZEN_COLS; c++) {
var td = document.createElement('td');
td.style.textAlign = 'right';
td.textContent = (Math.random() * 9999).toFixed(2);
tr.appendChild(td);
}
fragment.appendChild(tr);
}
document.getElementById('tbody').appendChild(fragment);
}

function setFrozenLeft() {
var cells = document.querySelectorAll('.frozen');
var leftOffsets = [];
var headerCells = document.querySelectorAll('thead .frozen');
var left = 0;
for (var i = 0; i < headerCells.length; i++) {
leftOffsets.push(left);
left += headerCells[i].offsetWidth;
}
for (var j = 0; j < cells.length; j++) {
var col = j % FROZEN_COLS;
cells[j].style.left = leftOffsets[col] + 'px';
}
}

buildHeader();
buildBody();
setFrozenLeft();
</script>
</body>
</html>