|
| 1 | +<template> |
| 2 | + <div class="piano-range-container"> |
| 3 | + <div class="piano-wrapper" ref="pianoWrapper"> |
| 4 | + <svg :width="svgWidth" :height="keyHeight + 10" class="piano-svg"> |
| 5 | + <!-- Background to show full piano range for context --> |
| 6 | + <g class="background-keys" opacity="0.15"> |
| 7 | + <template v-for="i in 88" :key="`bg-${i}`"> |
| 8 | + <rect |
| 9 | + v-if="isWhiteKey(i + 20)" |
| 10 | + :x="getKeyPosition(i + 20)" |
| 11 | + y="0" |
| 12 | + :width="whiteKeyWidth" |
| 13 | + :height="keyHeight" |
| 14 | + class="white-key" |
| 15 | + stroke="#999" |
| 16 | + stroke-width="1" |
| 17 | + /> |
| 18 | + </template> |
| 19 | + <template v-for="i in 88" :key="`bg-black-${i}`"> |
| 20 | + <rect |
| 21 | + v-if="!isWhiteKey(i + 20)" |
| 22 | + :x="getKeyPosition(i + 20)" |
| 23 | + y="0" |
| 24 | + :width="blackKeyWidth" |
| 25 | + :height="blackKeyHeight" |
| 26 | + class="black-key" |
| 27 | + /> |
| 28 | + </template> |
| 29 | + </g> |
| 30 | + |
| 31 | + <!-- Selected range highlighted --> |
| 32 | + <g class="selected-range"> |
| 33 | + <!-- White keys in selected range --> |
| 34 | + <template v-for="note in 128" :key="`white-${note}`"> |
| 35 | + <rect |
| 36 | + v-if="isWhiteKey(note) && note >= lowestNote && note <= highestNote" |
| 37 | + :x="getKeyPosition(note)" |
| 38 | + y="0" |
| 39 | + :width="whiteKeyWidth" |
| 40 | + :height="keyHeight" |
| 41 | + class="white-key active" |
| 42 | + stroke="#666" |
| 43 | + stroke-width="1" |
| 44 | + /> |
| 45 | + </template> |
| 46 | + |
| 47 | + <!-- Black keys in selected range --> |
| 48 | + <template v-for="note in 128" :key="`black-${note}`"> |
| 49 | + <rect |
| 50 | + v-if="!isWhiteKey(note) && note >= lowestNote && note <= highestNote" |
| 51 | + :x="getKeyPosition(note)" |
| 52 | + y="0" |
| 53 | + :width="blackKeyWidth" |
| 54 | + :height="blackKeyHeight" |
| 55 | + class="black-key active" |
| 56 | + /> |
| 57 | + </template> |
| 58 | + </g> |
| 59 | + |
| 60 | + <!-- Range markers --> |
| 61 | + <g class="range-markers"> |
| 62 | + <rect |
| 63 | + :x="getKeyPosition(lowestNote) - 2" |
| 64 | + y="0" |
| 65 | + :width="4" |
| 66 | + :height="keyHeight + 5" |
| 67 | + class="range-marker start" |
| 68 | + /> |
| 69 | + <rect |
| 70 | + :x="getKeyPosition(highestNote) + whiteKeyWidth - 2" |
| 71 | + y="0" |
| 72 | + :width="4" |
| 73 | + :height="keyHeight + 5" |
| 74 | + class="range-marker end" |
| 75 | + /> |
| 76 | + </g> |
| 77 | + |
| 78 | + <!-- Note labels at extremes --> |
| 79 | + <text :x="getKeyPosition(lowestNote) + 3" :y="keyHeight + 12" class="note-label"> |
| 80 | + {{ getNoteLabel(lowestNote) }} |
| 81 | + </text> |
| 82 | + <text |
| 83 | + :x="getKeyPosition(highestNote) + whiteKeyWidth - 3" |
| 84 | + :y="keyHeight + 12" |
| 85 | + text-anchor="end" |
| 86 | + class="note-label" |
| 87 | + > |
| 88 | + {{ getNoteLabel(highestNote) }} |
| 89 | + </text> |
| 90 | + </svg> |
| 91 | + </div> |
| 92 | + </div> |
| 93 | +</template> |
| 94 | + |
| 95 | +<script lang="ts"> |
| 96 | +import { defineComponent, ref, computed, onMounted } from 'vue'; |
| 97 | +
|
| 98 | +export default defineComponent({ |
| 99 | + name: 'PianoRangeVisualizer', |
| 100 | +
|
| 101 | + props: { |
| 102 | + lowestNote: { |
| 103 | + type: Number, |
| 104 | + required: true, |
| 105 | + }, |
| 106 | + highestNote: { |
| 107 | + type: Number, |
| 108 | + required: true, |
| 109 | + }, |
| 110 | + // Optional max width to fit in parent container |
| 111 | + maxWidth: { |
| 112 | + type: Number, |
| 113 | + default: 1000, |
| 114 | + }, |
| 115 | + }, |
| 116 | +
|
| 117 | + setup(props) { |
| 118 | + const pianoWrapper = ref<HTMLElement | null>(null); |
| 119 | + const containerWidth = ref(800); |
| 120 | +
|
| 121 | + // Piano dimensions |
| 122 | + const keyHeight = 100; |
| 123 | + const blackKeyHeight = 60; |
| 124 | +
|
| 125 | + // Calculate piano dimensions based on range |
| 126 | + // const keysInFullRange = 88; // Standard piano: A0 (21) to C8 (108) |
| 127 | + const totalWhiteKeys = computed(() => { |
| 128 | + let count = 0; |
| 129 | + for (let note = 21; note <= 108; note++) { |
| 130 | + if (isWhiteKey(note)) count++; |
| 131 | + } |
| 132 | + return count; |
| 133 | + }); |
| 134 | +
|
| 135 | + const whiteKeyWidth = computed(() => { |
| 136 | + return Math.min(22, containerWidth.value / Math.max(25, totalWhiteKeys.value)); |
| 137 | + }); |
| 138 | +
|
| 139 | + const blackKeyWidth = computed(() => whiteKeyWidth.value * 0.6); |
| 140 | +
|
| 141 | + const svgWidth = computed(() => { |
| 142 | + return Math.min(props.maxWidth, whiteKeyWidth.value * totalWhiteKeys.value + 10); |
| 143 | + }); |
| 144 | +
|
| 145 | + // Determine if a note is a white key |
| 146 | + const isWhiteKey = (noteNumber: number) => { |
| 147 | + const note = noteNumber % 12; |
| 148 | + return [0, 2, 4, 5, 7, 9, 11].includes(note); |
| 149 | + }; |
| 150 | +
|
| 151 | + // Position calculation for keys |
| 152 | + const getKeyPosition = (noteNumber: number) => { |
| 153 | + let whiteKeyCount = 0; |
| 154 | + for (let i = 21; i < noteNumber; i++) { |
| 155 | + if (isWhiteKey(i)) whiteKeyCount++; |
| 156 | + } |
| 157 | +
|
| 158 | + if (isWhiteKey(noteNumber)) { |
| 159 | + return whiteKeyCount * whiteKeyWidth.value; |
| 160 | + } else { |
| 161 | + // For black keys, position them between white keys |
| 162 | + return whiteKeyCount * whiteKeyWidth.value - blackKeyWidth.value / 2; |
| 163 | + } |
| 164 | + }; |
| 165 | +
|
| 166 | + const getNoteLabel = (noteNumber: number) => { |
| 167 | + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; |
| 168 | + const note = noteNumber % 12; |
| 169 | + const octave = Math.floor(noteNumber / 12) - 1; |
| 170 | + return `${noteNames[note]}${octave}`; |
| 171 | + }; |
| 172 | +
|
| 173 | + onMounted(() => { |
| 174 | + if (pianoWrapper.value) { |
| 175 | + containerWidth.value = pianoWrapper.value.clientWidth; |
| 176 | +
|
| 177 | + // Add resize listener to adjust for window size changes |
| 178 | + const resizeObserver = new ResizeObserver((entries) => { |
| 179 | + for (const entry of entries) { |
| 180 | + containerWidth.value = entry.contentRect.width; |
| 181 | + } |
| 182 | + }); |
| 183 | +
|
| 184 | + resizeObserver.observe(pianoWrapper.value); |
| 185 | + } |
| 186 | + }); |
| 187 | +
|
| 188 | + return { |
| 189 | + pianoWrapper, |
| 190 | + keyHeight, |
| 191 | + blackKeyHeight, |
| 192 | + whiteKeyWidth, |
| 193 | + blackKeyWidth, |
| 194 | + svgWidth, |
| 195 | + isWhiteKey, |
| 196 | + getKeyPosition, |
| 197 | + getNoteLabel, |
| 198 | + }; |
| 199 | + }, |
| 200 | +}); |
| 201 | +</script> |
| 202 | + |
| 203 | +<style scoped> |
| 204 | +.piano-range-container { |
| 205 | + margin: 20px 0; |
| 206 | +} |
| 207 | +
|
| 208 | +.piano-wrapper { |
| 209 | + width: 100%; |
| 210 | + overflow-x: hidden; |
| 211 | + margin: 10px 0; |
| 212 | +} |
| 213 | +
|
| 214 | +.piano-svg { |
| 215 | + display: block; |
| 216 | + width: 100%; |
| 217 | + height: auto; |
| 218 | +} |
| 219 | +
|
| 220 | +.white-key { |
| 221 | + fill: white; |
| 222 | +} |
| 223 | +
|
| 224 | +.black-key { |
| 225 | + fill: #333; |
| 226 | +} |
| 227 | +
|
| 228 | +.white-key.active { |
| 229 | + fill: #e6f2ff; |
| 230 | +} |
| 231 | +
|
| 232 | +.black-key.active { |
| 233 | + fill: #1a1a1a; |
| 234 | +} |
| 235 | +
|
| 236 | +.range-marker { |
| 237 | + fill: #ff5252; |
| 238 | +} |
| 239 | +
|
| 240 | +.note-label { |
| 241 | + font-size: 12px; |
| 242 | + fill: #333; |
| 243 | + font-weight: bold; |
| 244 | +} |
| 245 | +</style> |
0 commit comments