text-guitar-chart 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +18 -0
- package/FORMAT.md +277 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/docs/app.js +172 -0
- package/docs/bundle.js +8712 -0
- package/docs/bundle.js.map +7 -0
- package/docs/index.html +93 -0
- package/index.js +7 -0
- package/lib/editableSVGuitar.js +1050 -0
- package/lib/fingeringToString.js +261 -0
- package/lib/layoutChordStrings.js +92 -0
- package/lib/splitStringInRectangles.js +132 -0
- package/lib/stringToFingering.js +435 -0
- package/package.json +43 -0
- package/scripts/build.js +30 -0
- package/test/editableSVGuitar.test.js +444 -0
- package/test/fingeringToString.test.js +705 -0
- package/test/layoutChordStrings.test.js +193 -0
- package/test/splitStringInRectangles.test.js +98 -0
- package/test/stringToFingering.test.js +1086 -0
- package/tsconfig.json +25 -0
- package/types/editableSVGuitar.d.ts +209 -0
- package/types/fingeringToString.d.ts +10 -0
- package/types/layoutChordStrings.d.ts +10 -0
- package/types/splitStringInRectangles.d.ts +18 -0
- package/types/stringToFingering.d.ts +10 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parse a string representation of a guitar fingering into internal format
|
|
5
|
+
* @param {import("svguitar").Chord} chord
|
|
6
|
+
* @param {object} [options]
|
|
7
|
+
* @param {boolean} [options.useUnicode=false] - Whether to use Unicode characters for string/fret markers
|
|
8
|
+
* @returns {string}
|
|
9
|
+
*/
|
|
10
|
+
export default function fingeringToString(chord, options = {}) {
|
|
11
|
+
const { useUnicode = false } = options;
|
|
12
|
+
const { fingers = [], title = "", position } = chord;
|
|
13
|
+
|
|
14
|
+
// Parse fingers to build data structure
|
|
15
|
+
const stringData = new Map(); // Map<string, Map<fret, fingerInfo>>
|
|
16
|
+
let maxFret = 0;
|
|
17
|
+
const openStrings = new Set();
|
|
18
|
+
const mutedStrings = new Set();
|
|
19
|
+
|
|
20
|
+
for (const finger of fingers) {
|
|
21
|
+
const [string, fret, opts = {}] = finger;
|
|
22
|
+
const optsObject = typeof opts === "object" ? opts : {};
|
|
23
|
+
const { text = "", color = "#000000" } = optsObject;
|
|
24
|
+
|
|
25
|
+
if (fret === 0) {
|
|
26
|
+
openStrings.add(string);
|
|
27
|
+
} else if (fret === "x") {
|
|
28
|
+
mutedStrings.add(string);
|
|
29
|
+
} else {
|
|
30
|
+
if (!stringData.has(string)) {
|
|
31
|
+
stringData.set(string, new Map());
|
|
32
|
+
}
|
|
33
|
+
stringData.get(string).set(fret, { text, color });
|
|
34
|
+
|
|
35
|
+
if (fret > maxFret) maxFret = fret;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Determine number of frets to show
|
|
40
|
+
const numFrets = fingers.some(f => typeof f[1] === "number" && f[1] > 0)
|
|
41
|
+
? Math.max(3, maxFret)
|
|
42
|
+
: 3;
|
|
43
|
+
|
|
44
|
+
if (useUnicode) {
|
|
45
|
+
return buildUnicodeOutput(
|
|
46
|
+
title,
|
|
47
|
+
stringData,
|
|
48
|
+
openStrings,
|
|
49
|
+
mutedStrings,
|
|
50
|
+
numFrets,
|
|
51
|
+
position
|
|
52
|
+
);
|
|
53
|
+
} else {
|
|
54
|
+
return buildAsciiOutput(
|
|
55
|
+
title,
|
|
56
|
+
stringData,
|
|
57
|
+
openStrings,
|
|
58
|
+
mutedStrings,
|
|
59
|
+
numFrets,
|
|
60
|
+
position
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build ASCII format output
|
|
67
|
+
* @param {string} title
|
|
68
|
+
* @param {Map<number, Map<number, {text: string, color: string}>>} stringData
|
|
69
|
+
* @param {Set<number>} openStrings
|
|
70
|
+
* @param {Set<number>} mutedStrings
|
|
71
|
+
* @param {number} numFrets
|
|
72
|
+
* @param {number|undefined} position
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
function buildAsciiOutput(
|
|
76
|
+
title,
|
|
77
|
+
stringData,
|
|
78
|
+
openStrings,
|
|
79
|
+
mutedStrings,
|
|
80
|
+
numFrets,
|
|
81
|
+
position
|
|
82
|
+
) {
|
|
83
|
+
const lines = [];
|
|
84
|
+
|
|
85
|
+
// Title section
|
|
86
|
+
if (title && title.length > 0) {
|
|
87
|
+
const clampedTitle = title.length > 15 ? title.slice(0, 15) : title;
|
|
88
|
+
lines.push(` ${clampedTitle}`);
|
|
89
|
+
// Add separator line of # characters (exactly 6 chars minimum)
|
|
90
|
+
lines.push(` ${'#'.repeat(Math.max(6, clampedTitle.length))}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Open/muted strings line - only if there are any
|
|
94
|
+
if (openStrings.size > 0 || mutedStrings.size > 0) {
|
|
95
|
+
let openLine = " ";
|
|
96
|
+
let lowestMarked = 6; // lowest numbered string (rightmost in display) that has a marker
|
|
97
|
+
|
|
98
|
+
// Find lowest numbered string with a marker
|
|
99
|
+
for (let str = 6; str >= 1; str--) {
|
|
100
|
+
if (openStrings.has(str) || mutedStrings.has(str)) {
|
|
101
|
+
lowestMarked = str;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Show from string 6 down to lowestMarked, extending to 3 only if lowestMarked > 4
|
|
106
|
+
const showTo = lowestMarked > 4 ? 3 : lowestMarked;
|
|
107
|
+
|
|
108
|
+
// Build line from string 6 down to showTo
|
|
109
|
+
for (let str = 6; str >= showTo; str--) {
|
|
110
|
+
if (openStrings.has(str)) {
|
|
111
|
+
openLine += "o";
|
|
112
|
+
} else if (mutedStrings.has(str)) {
|
|
113
|
+
openLine += "x";
|
|
114
|
+
} else {
|
|
115
|
+
openLine += " ";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lines.push(openLine.trimEnd());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Separator line before fretboard (always included)
|
|
123
|
+
lines.push(position === 1 ? " ======" : " ------");
|
|
124
|
+
|
|
125
|
+
// Fret lines
|
|
126
|
+
for (let fret = 1; fret <= numFrets; fret++) {
|
|
127
|
+
let line = "";
|
|
128
|
+
|
|
129
|
+
// Add position number on first fret line if specified
|
|
130
|
+
if (fret === 1 && position !== undefined && position !== 1) {
|
|
131
|
+
line = position < 10 ? ` ${position}` : `${position}`;
|
|
132
|
+
} else {
|
|
133
|
+
line = " ";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Build fret line (strings 6 to 1, left to right)
|
|
137
|
+
for (let str = 6; str >= 1; str--) {
|
|
138
|
+
const fingerInfo = stringData.get(str)?.get(fret);
|
|
139
|
+
|
|
140
|
+
if (fingerInfo) {
|
|
141
|
+
if (fingerInfo.color !== "#000000") {
|
|
142
|
+
line += "*";
|
|
143
|
+
} else if (fingerInfo.text) {
|
|
144
|
+
line += fingerInfo.text[0];
|
|
145
|
+
} else {
|
|
146
|
+
line += "o";
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
line += "|";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
lines.push(line);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Build Unicode format output
|
|
161
|
+
* @param {string} title
|
|
162
|
+
* @param {Map<number, Map<number, {text: string, color: string}>>} stringData
|
|
163
|
+
* @param {Set<number>} openStrings
|
|
164
|
+
* @param {Set<number>} mutedStrings
|
|
165
|
+
* @param {number} numFrets
|
|
166
|
+
* @param {number|undefined} position
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
function buildUnicodeOutput(
|
|
170
|
+
title,
|
|
171
|
+
stringData,
|
|
172
|
+
openStrings,
|
|
173
|
+
mutedStrings,
|
|
174
|
+
numFrets,
|
|
175
|
+
position
|
|
176
|
+
) {
|
|
177
|
+
const lines = [];
|
|
178
|
+
|
|
179
|
+
// Title section
|
|
180
|
+
if (title && title.length > 0) {
|
|
181
|
+
const clampedTitle = title.length > 15 ? title.slice(0, 15) : title;
|
|
182
|
+
lines.push(` ${clampedTitle}`);
|
|
183
|
+
// Add separator line of # characters (exactly 6 chars minimum)
|
|
184
|
+
lines.push(` ${'‾'.repeat(Math.max(11, clampedTitle.length))}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Open/muted strings line - only if there are any
|
|
188
|
+
if (openStrings.size > 0 || mutedStrings.size > 0) {
|
|
189
|
+
let openLine = " ";
|
|
190
|
+
let lowestMarked = 6;
|
|
191
|
+
|
|
192
|
+
// Find lowest numbered string with a marker
|
|
193
|
+
for (let str = 6; str >= 1; str--) {
|
|
194
|
+
if (openStrings.has(str) || mutedStrings.has(str)) {
|
|
195
|
+
lowestMarked = str;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Show from string 6 down to lowestMarked, extending to 3 only if lowestMarked > 4
|
|
200
|
+
const showTo = lowestMarked > 4 ? 3 : lowestMarked;
|
|
201
|
+
|
|
202
|
+
// Build line from string 6 down to showTo with spaces between characters
|
|
203
|
+
const chars = [];
|
|
204
|
+
for (let str = 6; str >= showTo; str--) {
|
|
205
|
+
if (openStrings.has(str)) {
|
|
206
|
+
chars.push("○");
|
|
207
|
+
} else if (mutedStrings.has(str)) {
|
|
208
|
+
chars.push("×");
|
|
209
|
+
} else {
|
|
210
|
+
chars.push(" ");
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
openLine += chars.join(" ");
|
|
214
|
+
|
|
215
|
+
lines.push(openLine.trimEnd());
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Top border
|
|
219
|
+
lines.push(position === 1 ? " ╒═╤═╤═╤═╤═╕" : " ┌─┬─┬─┬─┬─┐");
|
|
220
|
+
|
|
221
|
+
// Fret lines
|
|
222
|
+
for (let fret = 1; fret <= numFrets; fret++) {
|
|
223
|
+
let line = "";
|
|
224
|
+
|
|
225
|
+
// Add position number on first fret line if specified
|
|
226
|
+
if (fret === 1 && position !== undefined && position > 1) {
|
|
227
|
+
line = position < 10 ? ` ${position}` : `${position}`;
|
|
228
|
+
} else {
|
|
229
|
+
line = " ";
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Build fret line (strings 6 to 1, left to right)
|
|
233
|
+
for (let str = 6; str >= 1; str--) {
|
|
234
|
+
const fingerInfo = stringData.get(str)?.get(fret);
|
|
235
|
+
|
|
236
|
+
if (fingerInfo) {
|
|
237
|
+
if (fingerInfo.color !== "#000000") {
|
|
238
|
+
line += "●";
|
|
239
|
+
} else if (fingerInfo.text) {
|
|
240
|
+
line += fingerInfo.text[0];
|
|
241
|
+
} else {
|
|
242
|
+
line += "○";
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
line += "│";
|
|
246
|
+
}
|
|
247
|
+
if (str > 1) line += " ";
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
lines.push(line);
|
|
251
|
+
|
|
252
|
+
// Add separator or bottom border
|
|
253
|
+
if (fret < numFrets) {
|
|
254
|
+
lines.push(" ├─┼─┼─┼─┼─┤");
|
|
255
|
+
} else {
|
|
256
|
+
lines.push(" └─┴─┴─┴─┴─┘");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return lines.join("\n");
|
|
261
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Arranges an array of multi-line chord strings into a grid layout with proper padding.
|
|
5
|
+
* Empty strings are filtered out and trailing spaces are trimmed from each output line.
|
|
6
|
+
*
|
|
7
|
+
* @param {Array<string>} strings - Array of chord strings (can be multi-line)
|
|
8
|
+
* @param {number} columns - Number of columns per row (default: 3)
|
|
9
|
+
* @param {number} spacing - Number of spaces between columns (default: 1)
|
|
10
|
+
* @returns {string} - Formatted grid layout string
|
|
11
|
+
*/
|
|
12
|
+
export default function layoutChordStrings(strings, columns = 3, spacing = 1) {
|
|
13
|
+
// Filter out empty strings
|
|
14
|
+
const filtered = strings.filter(s => s !== '');
|
|
15
|
+
|
|
16
|
+
// Handle empty array
|
|
17
|
+
if (filtered.length === 0) {
|
|
18
|
+
return '';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Trim trailing newlines from each string
|
|
22
|
+
const trimmed = filtered.map(s => s.replace(/\n+$/, ''));
|
|
23
|
+
|
|
24
|
+
// Split each string into lines
|
|
25
|
+
const stringLines = trimmed.map(s => s.split('\n'));
|
|
26
|
+
|
|
27
|
+
// Group strings into rows
|
|
28
|
+
const rows = [];
|
|
29
|
+
for (let i = 0; i < stringLines.length; i += columns) {
|
|
30
|
+
rows.push(stringLines.slice(i, i + columns));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Calculate max width for each column across all rows
|
|
34
|
+
const columnWidths = new Array(columns).fill(0);
|
|
35
|
+
for (const row of rows) {
|
|
36
|
+
row.forEach((lines, colIndex) => {
|
|
37
|
+
const maxWidth = Math.max(...lines.map(line => line.length));
|
|
38
|
+
columnWidths[colIndex] = Math.max(columnWidths[colIndex], maxWidth);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Build output
|
|
43
|
+
const outputRows = [];
|
|
44
|
+
|
|
45
|
+
// Determine spacing strategy based on column width uniformity
|
|
46
|
+
// If all columns have the same width of 1: use fixed separator with spacing
|
|
47
|
+
// If all columns have the same width > 1: use 1-space separator (unless spacing overrides)
|
|
48
|
+
// If columns have different widths: use variable padding for better alignment
|
|
49
|
+
const allWidthsEqual = columnWidths.length > 0 && columnWidths.every(w => w === columnWidths[0]);
|
|
50
|
+
const firstWidth = columnWidths[0] || 0;
|
|
51
|
+
const useFixedSpacing = allWidthsEqual && firstWidth === 1;
|
|
52
|
+
|
|
53
|
+
for (const row of rows) {
|
|
54
|
+
// Find max height in this row
|
|
55
|
+
const rowHeight = Math.max(...row.map(lines => lines.length));
|
|
56
|
+
|
|
57
|
+
// Build each line of this row
|
|
58
|
+
const rowLines = [];
|
|
59
|
+
for (let lineIdx = 0; lineIdx < rowHeight; lineIdx++) {
|
|
60
|
+
const lineParts = [];
|
|
61
|
+
for (let colIdx = 0; colIdx < columns; colIdx++) {
|
|
62
|
+
const lines = colIdx < row.length ? row[colIdx] : [];
|
|
63
|
+
const line = lineIdx < lines.length ? lines[lineIdx] : '';
|
|
64
|
+
|
|
65
|
+
if (!useFixedSpacing && colIdx < columns - 1 && colIdx < columnWidths.length - 1) {
|
|
66
|
+
// Variable padding: based on column widths
|
|
67
|
+
// Pad to max(current_width + 1, next_width + 1) to ensure proper alignment
|
|
68
|
+
const padWidth = Math.max(columnWidths[colIdx], columnWidths[colIdx + 1]);
|
|
69
|
+
lineParts.push(line.padEnd(padWidth + spacing));
|
|
70
|
+
} else {
|
|
71
|
+
// Fixed spacing or last column: pad to column width
|
|
72
|
+
lineParts.push(line.padEnd(columnWidths[colIdx]));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Join and trim
|
|
77
|
+
if (useFixedSpacing) {
|
|
78
|
+
// Fixed spacing: join with spacing + 1 separator
|
|
79
|
+
const separator = ' '.repeat(spacing + 1);
|
|
80
|
+
rowLines.push(lineParts.join(separator).trimEnd());
|
|
81
|
+
} else {
|
|
82
|
+
// Variable padding: parts already include spacing
|
|
83
|
+
rowLines.push(lineParts.join('').trimEnd());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
outputRows.push(rowLines.join('\n'));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Use spacing to determine the number of blank lines between rows
|
|
90
|
+
const rowSeparator = '\n'.repeat(spacing + 1);
|
|
91
|
+
return outputRows.join(rowSeparator);
|
|
92
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* This function takes a string and splits it into an array of strings,
|
|
5
|
+
* each of the strings is a rectangle present in the original string and
|
|
6
|
+
* separated by spaces
|
|
7
|
+
*
|
|
8
|
+
* How it works:
|
|
9
|
+
* - convert the string into a 2D array of characters
|
|
10
|
+
* - scan the array left to right and top to bottom to find a non space character
|
|
11
|
+
* - once found search all adjacent non space characters using breadth first search,
|
|
12
|
+
* Consider horizontal, vertical and diagonal adjacency
|
|
13
|
+
* - once exhausted the search, determine the rectangle that bounds all found characters
|
|
14
|
+
* - extract the rectangle as a string and add it to the result array
|
|
15
|
+
* - mark all characters in the rectangle as visited (set to space)
|
|
16
|
+
* - continue scanning the array until all characters are visited
|
|
17
|
+
* @param {string} str
|
|
18
|
+
* @returns {Array<string>}
|
|
19
|
+
*/
|
|
20
|
+
export default function splitStringInRectangles(str) {
|
|
21
|
+
// Early validation
|
|
22
|
+
if (!str || str.trim() === '') {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Convert string to 2D array of characters
|
|
27
|
+
const grid = str.split('\n').map(line => line.split(''));
|
|
28
|
+
const result = [];
|
|
29
|
+
|
|
30
|
+
// 8 directions: horizontal, vertical, and diagonal
|
|
31
|
+
const directions = [
|
|
32
|
+
[-1, -1], [-1, 0], [-1, 1],
|
|
33
|
+
[0, -1], [0, 1],
|
|
34
|
+
[1, -1], [1, 0], [1, 1]
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* BFS to find all connected non-space characters
|
|
39
|
+
* @param {number} startRow
|
|
40
|
+
* @param {number} startCol
|
|
41
|
+
* @returns {{minRow: number, maxRow: number, minCol: number, maxCol: number, positions: Array<[number, number]>}}
|
|
42
|
+
*/
|
|
43
|
+
function bfs(startRow, startCol) {
|
|
44
|
+
const queue = [[startRow, startCol]];
|
|
45
|
+
/** @type {Array<[number, number]>} */
|
|
46
|
+
const positions = [[startRow, startCol]];
|
|
47
|
+
let minRow = startRow;
|
|
48
|
+
let maxRow = startRow;
|
|
49
|
+
let minCol = startCol;
|
|
50
|
+
let maxCol = startCol;
|
|
51
|
+
|
|
52
|
+
// Mark starting position as visited
|
|
53
|
+
grid[startRow][startCol] = ' ';
|
|
54
|
+
|
|
55
|
+
while (queue.length > 0) {
|
|
56
|
+
const [row, col] = queue.shift();
|
|
57
|
+
|
|
58
|
+
// Check all 8 directions
|
|
59
|
+
for (const [dr, dc] of directions) {
|
|
60
|
+
const newRow = row + dr;
|
|
61
|
+
const newCol = col + dc;
|
|
62
|
+
|
|
63
|
+
// Check bounds
|
|
64
|
+
if (newRow >= 0 && newRow < grid.length &&
|
|
65
|
+
newCol >= 0 && newCol < grid[newRow].length) {
|
|
66
|
+
// Check if non-space character
|
|
67
|
+
if (grid[newRow][newCol] !== ' ') {
|
|
68
|
+
queue.push([newRow, newCol]);
|
|
69
|
+
positions.push([newRow, newCol]);
|
|
70
|
+
|
|
71
|
+
// Update bounding rectangle
|
|
72
|
+
minRow = Math.min(minRow, newRow);
|
|
73
|
+
maxRow = Math.max(maxRow, newRow);
|
|
74
|
+
minCol = Math.min(minCol, newCol);
|
|
75
|
+
maxCol = Math.max(maxCol, newCol);
|
|
76
|
+
|
|
77
|
+
// Mark as visited
|
|
78
|
+
grid[newRow][newCol] = ' ';
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { minRow, maxRow, minCol, maxCol, positions };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Extract rectangle string from positions
|
|
89
|
+
* @param {{minRow: number, maxRow: number, minCol: number, maxCol: number, positions: Array<[number, number]>}} bounds
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
function extractRectangle(bounds) {
|
|
93
|
+
const { minRow, maxRow, minCol, maxCol, positions } = bounds;
|
|
94
|
+
const height = maxRow - minRow + 1;
|
|
95
|
+
const width = maxCol - minCol + 1;
|
|
96
|
+
|
|
97
|
+
// Create 2D array filled with spaces
|
|
98
|
+
const rectGrid = Array(height).fill(null).map(() => Array(width).fill(' '));
|
|
99
|
+
|
|
100
|
+
// Place characters at their relative positions
|
|
101
|
+
for (const [row, col] of positions) {
|
|
102
|
+
const relRow = row - minRow;
|
|
103
|
+
const relCol = col - minCol;
|
|
104
|
+
// Get original character from input (before it was marked as space)
|
|
105
|
+
const lines = str.split('\n');
|
|
106
|
+
if (row < lines.length && col < lines[row].length) {
|
|
107
|
+
rectGrid[relRow][relCol] = lines[row][col];
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Convert to string, preserving internal spaces
|
|
112
|
+
return rectGrid
|
|
113
|
+
.map(row => row.join(''))
|
|
114
|
+
.join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Scan grid left to right, top to bottom
|
|
118
|
+
for (let row = 0; row < grid.length; row++) {
|
|
119
|
+
for (let col = 0; col < grid[row].length; col++) {
|
|
120
|
+
// Find non-space character
|
|
121
|
+
if (grid[row][col] !== ' ') {
|
|
122
|
+
// BFS to find all connected characters
|
|
123
|
+
const bounds = bfs(row, col);
|
|
124
|
+
// Extract rectangle and add to result
|
|
125
|
+
const rectangle = extractRectangle(bounds);
|
|
126
|
+
result.push(rectangle);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return result;
|
|
132
|
+
}
|