function main(workbook: ExcelScript.Workbook) {
const sheet = workbook.getActiveWorksheet();
// ==============================
// 設定
// ==============================
const HEADER_ROW_INDEX = 0; // 1行目
const FIRST_DATA_ROW_INDEX = 1; // 2行目
const PLANNED_START_COLUMN = 6; // G列
const PLANNED_END_COLUMN = 7; // H列
const ACTUAL_START_COLUMN = 8; // I列
const ACTUAL_END_COLUMN = 9; // J列
const TIMELINE_START_COLUMN = 10; // K列
const PLANNED_COLOR = "#9DC3E6"; // 予定:水色
const ACTUAL_COLOR = "#70AD47"; // 実績:緑
const DELAY_COLOR = "#FF6B6B"; // 遅延:赤
const WEEKEND_COLOR = "#F2F2F2"; // 土日:薄い灰色
const HEADER_COLOR = "#D9EAF7";
// 実績開始日があり、実績終了日が空欄の場合、
// 今日までを実績期間として表示する。
const DRAW_OPEN_ACTUAL_TO_TODAY = true;
// 異常に長い期間が入力されていた場合の安全制限。
const MAX_TIMELINE_DAYS = 1095; // 約3年
// ==============================
// 使用範囲と最終行を取得
// ==============================
const usedRange = sheet.getUsedRange(true);
if (!usedRange) {
throw new Error("ワークシートにデータがありません。");
}
const lastRowExclusive =
usedRange.getRowIndex() + usedRange.getRowCount();
const dataRowCount =
lastRowExclusive - FIRST_DATA_ROW_INDEX;
if (dataRowCount <= 0) {
throw new Error("2行目以降にタスクデータがありません。");
}
// G:Jをまとめて取得
const dateRange = sheet.getRangeByIndexes(
FIRST_DATA_ROW_INDEX,
PLANNED_START_COLUMN,
dataRowCount,
4
);
const dateValues = dateRange.getValues();
const todaySerial = getTodayExcelSerial();
interface TaskDates {
plannedStart: number | null;
plannedEnd: number | null;
actualStart: number | null;
actualEnd: number | null;
}
const tasks: TaskDates[] = [];
let minimumDate = Number.POSITIVE_INFINITY;
let maximumDate = Number.NEGATIVE_INFINITY;
// ==============================
// 日付をExcelシリアル値へ変換
// ==============================
for (let row = 0; row < dateValues.length; row++) {
const plannedStart = normalizeExcelDate(dateValues[row][0]);
const plannedEnd = normalizeExcelDate(dateValues[row][1]);
const actualStart = normalizeExcelDate(dateValues[row][2]);
let actualEnd = normalizeExcelDate(dateValues[row][3]);
if (
DRAW_OPEN_ACTUAL_TO_TODAY &&
actualStart !== null &&
actualEnd === null
) {
actualEnd = Math.max(actualStart, todaySerial);
}
const task: TaskDates = {
plannedStart,
plannedEnd,
actualStart,
actualEnd
};
tasks.push(task);
const dates = [
plannedStart,
plannedEnd,
actualStart,
actualEnd
];
for (const date of dates) {
if (date !== null) {
minimumDate = Math.min(minimumDate, date);
maximumDate = Math.max(maximumDate, date);
}
}
}
if (
!Number.isFinite(minimumDate) ||
!Number.isFinite(maximumDate)
) {
throw new Error(
"G列からJ列に有効な日付がありません。Excelの日付形式で入力してください。"
);
}
const timelineDays =
maximumDate - minimumDate + 1;
if (timelineDays > MAX_TIMELINE_DAYS) {
throw new Error(
表示期間が${timelineDays}日あります。 +
日付の入力誤りがないか確認してください。
);
}
// ==============================
// ガントチャート範囲を初期化
// ==============================
const timelineArea = sheet.getRangeByIndexes(
HEADER_ROW_INDEX,
TIMELINE_START_COLUMN,
lastRowExclusive - HEADER_ROW_INDEX,
timelineDays
);
// K列以降の今回使用する範囲を消去
timelineArea.clear(ExcelScript.ClearApplyTo.all);
// ==============================
// 日付ヘッダーを作成
// ==============================
const headerValues: string[] = [];
for (let day = 0; day < timelineDays; day++) {
headerValues.push(
excelSerialToLabel(minimumDate + day)
);
}
const headerRange = sheet.getRangeByIndexes(
HEADER_ROW_INDEX,
TIMELINE_START_COLUMN,
1,
timelineDays
);
headerRange.setValues([headerValues]);
headerRange
.getFormat()
.getFill()
.setColor(HEADER_COLOR);
headerRange
.getFormat()
.getFont()
.setBold(true);
headerRange
.getFormat()
.setHorizontalAlignment(
ExcelScript.HorizontalAlignment.center
);
headerRange
.getFormat()
.setVerticalAlignment(
ExcelScript.VerticalAlignment.center
);
// 日付を縦向きに表示
headerRange
.getFormat()
.setTextOrientation(90);
headerRange
.getFormat()
.setRowHeight(60);
// 1日分の列幅
timelineArea
.getFormat()
.setColumnWidth(24);
// ==============================
// 土日を薄い灰色にする
// ==============================
for (let day = 0; day < timelineDays; day++) {
const serial = minimumDate + day;
const dayOfWeek = getDayOfWeek(serial);
// 0 = 日曜日、6 = 土曜日
if (dayOfWeek === 0 || dayOfWeek === 6) {
const weekendRange = sheet.getRangeByIndexes(
FIRST_DATA_ROW_INDEX,
TIMELINE_START_COLUMN + day,
dataRowCount,
1
);
weekendRange
.getFormat()
.getFill()
.setColor(WEEKEND_COLOR);
}
}
// ==============================
// 各タスクのバーを描画
// ==============================
for (let row = 0; row < tasks.length; row++) {
const sheetRow = FIRST_DATA_ROW_INDEX + row;
const task = tasks[row];
// ------------------------------
// 予定期間:水色
// ------------------------------
if (
task.plannedStart !== null &&
task.plannedEnd !== null &&
task.plannedStart <= task.plannedEnd
) {
paintDateRange(
sheet,
sheetRow,
TIMELINE_START_COLUMN,
minimumDate,
task.plannedStart,
task.plannedEnd,
PLANNED_COLOR
);
}
// ------------------------------
// 実績期間
// ------------------------------
if (
task.actualStart !== null &&
task.actualEnd !== null &&
task.actualStart <= task.actualEnd
) {
/*
* 予定終了日を超えていない実績:
* 緑色
*/
let normalActualEnd = task.actualEnd;
if (task.plannedEnd !== null) {
normalActualEnd = Math.min(
task.actualEnd,
task.plannedEnd
);
}
if (task.actualStart <= normalActualEnd) {
paintDateRange(
sheet,
sheetRow,
TIMELINE_START_COLUMN,
minimumDate,
task.actualStart,
normalActualEnd,
ACTUAL_COLOR
);
}
/*
* 予定終了日を超えた実績:
* 赤色
*/
if (
task.plannedEnd !== null &&
task.actualEnd > task.plannedEnd
) {
const delayStart = Math.max(
task.actualStart,
task.plannedEnd + 1
);
paintDateRange(
sheet,
sheetRow,
TIMELINE_START_COLUMN,
minimumDate,
delayStart,
task.actualEnd,
DELAY_COLOR
);
}
/*
* 予定終了日がない場合は、
* 実績期間全体を緑色にする。
*/
if (task.plannedEnd === null) {
paintDateRange(
sheet,
sheetRow,
TIMELINE_START_COLUMN,
minimumDate,
task.actualStart,
task.actualEnd,
ACTUAL_COLOR
);
}
}
}
console.log(
ガントチャートを作成しました。 +
期間:${excelSerialToLabel(minimumDate)} ~ +
${excelSerialToLabel(maximumDate)}
);
}
/**
- 指定した日付範囲を塗りつぶす。
*/
function paintDateRange(
sheet: ExcelScript.Worksheet,
sheetRow: number,
timelineStartColumn: number,
minimumDate: number,
startDate: number,
endDate: number,
color: string
): void {
if (startDate > endDate) {
return;
}
const startOffset = startDate - minimumDate;
const length = endDate - startDate + 1;
const barRange = sheet.getRangeByIndexes(
sheetRow,
timelineStartColumn + startOffset,
1,
length
);
barRange
.getFormat()
.getFill()
.setColor(color);
}
/**
- Excelセルの値を日付シリアル値へ変換する。
- Excelの日付セルは通常numberとして取得される。
- yyyy/mm/dd、yyyy-mm-dd形式の文字列にも対応。
*/
function normalizeExcelDate(
value: string | number | boolean
): number | null {
if (
typeof value === "number" &&
Number.isFinite(value)
) {
return Math.floor(value);
}
if (typeof value !== "string") {
return null;
}
const text = value.trim();
if (text === "") {
return null;
}
const match = text.match(
/^(\d{4})/-/-$/
);
if (match) {
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const utcMilliseconds = Date.UTC(
year,
month - 1,
day
);
return Math.floor(
utcMilliseconds / 86400000 + 25569
);
}
const parsedDate = new Date(text);
if (Number.isNaN(parsedDate.getTime())) {
return null;
}
const utcMilliseconds = Date.UTC(
parsedDate.getFullYear(),
parsedDate.getMonth(),
parsedDate.getDate()
);
return Math.floor(
utcMilliseconds / 86400000 + 25569
);
}
/**
- Excelシリアル値を「月/日」形式へ変換する。
*/
function excelSerialToLabel(serial: number): string {
const milliseconds =
(serial - 25569) * 86400000;
const date = new Date(milliseconds);
const year = date.getUTCFullYear();
const month = date.getUTCMonth() + 1;
const day = date.getUTCDate();
return ${year}/${month}/${day};
}
/**
- Excelシリアル値から曜日を取得する。
- 0:日曜日、6:土曜日
*/
function getDayOfWeek(serial: number): number {
const milliseconds =
(serial - 25569) * 86400000;
return new Date(milliseconds).getUTCDay();
}
/**
- 今日の日付をExcelシリアル値で取得する。
*/
function getTodayExcelSerial(): number {
const now = new Date();
const utcMilliseconds = Date.UTC(
now.getUTCFullYear(),
now.getUTCMonth(),
now.getUTCDate()
);
return Math.floor(
utcMilliseconds / 86400000 + 25569
);
}